mirror of
https://github.com/infinilabs/coco-app.git
synced 2025-12-19 12:59:24 +01:00
Compare commits
33 Commits
refactor-h
...
release_pr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9421180dba | ||
|
|
db07dec505 | ||
|
|
386ebb60c0 | ||
|
|
17c7227a44 | ||
|
|
23faaf6fc3 | ||
|
|
3131d3cea4 | ||
|
|
3014dc8839 | ||
|
|
829d3868c4 | ||
|
|
6584504142 | ||
|
|
01c51d83d6 | ||
|
|
29442826c5 | ||
|
|
e249c02123 | ||
|
|
7ac4508e8d | ||
|
|
450baccc92 | ||
|
|
bd0c9a740b | ||
|
|
fca11a9001 | ||
|
|
1aa30ee5bc | ||
|
|
cdaa151028 | ||
|
|
fd8d5819b8 | ||
|
|
4a5a4da399 | ||
|
|
efaaf73cd7 | ||
|
|
86540ad1a9 | ||
|
|
950482608d | ||
|
|
412c8d8612 | ||
|
|
de3c78a5aa | ||
|
|
eafa704ca5 | ||
|
|
86357079f8 | ||
|
|
ed118151cc | ||
|
|
50b26e2d9e | ||
|
|
a4aacc16d9 | ||
|
|
9aa7d23632 | ||
|
|
99b316da19 | ||
|
|
828c84762b |
12
.github/workflows/release.yml
vendored
12
.github/workflows/release.yml
vendored
@@ -104,7 +104,17 @@ jobs:
|
|||||||
if: startsWith(matrix.platform, 'ubuntu-22.04')
|
if: startsWith(matrix.platform, 'ubuntu-22.04')
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf xdg-utils
|
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf xdg-utils libtracker-sparql-3.0-dev
|
||||||
|
|
||||||
|
|
||||||
|
# On Windows, we need to generate bindings for 'searchapi.h' using bindgen.
|
||||||
|
# And bindgen relies on 'libclang'
|
||||||
|
# https://rust-lang.github.io/rust-bindgen/requirements.html#windows
|
||||||
|
- name: Install dependencies (Windows only)
|
||||||
|
if: startsWith(matrix.platform, 'windows-latest')
|
||||||
|
shell: bash
|
||||||
|
run: winget install LLVM.LLVM --silent --accept-package-agreements --accept-source-agreements
|
||||||
|
|
||||||
|
|
||||||
- name: Add Rust build target
|
- name: Add Rust build target
|
||||||
working-directory: src-tauri
|
working-directory: src-tauri
|
||||||
|
|||||||
10
.github/workflows/rust_code_check.yml
vendored
10
.github/workflows/rust_code_check.yml
vendored
@@ -30,7 +30,15 @@ jobs:
|
|||||||
if: startsWith(matrix.platform, 'ubuntu-latest')
|
if: startsWith(matrix.platform, 'ubuntu-latest')
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf xdg-utils
|
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf xdg-utils libtracker-sparql-3.0-dev
|
||||||
|
|
||||||
|
# On Windows, we need to generate bindings for 'searchapi.h' using bindgen.
|
||||||
|
# And bindgen relies on 'libclang'
|
||||||
|
# https://rust-lang.github.io/rust-bindgen/requirements.html#windows
|
||||||
|
- name: Install dependencies (Windows only)
|
||||||
|
if: startsWith(matrix.platform, 'windows-latest')
|
||||||
|
shell: bash
|
||||||
|
run: winget install LLVM.LLVM --silent --accept-package-agreements --accept-source-agreements
|
||||||
|
|
||||||
- name: Add pizza engine as a dependency
|
- name: Add pizza engine as a dependency
|
||||||
working-directory: src-tauri
|
working-directory: src-tauri
|
||||||
|
|||||||
@@ -64,9 +64,9 @@ At Coco AI, we aim to streamline workplace collaboration by centralizing access
|
|||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- Node.js >= 18.12
|
- [Node.js >= 18.12](https://nodejs.org/en/download/)
|
||||||
- Rust (latest stable)
|
- [Rust (latest stable)](https://www.rust-lang.org/tools/install)
|
||||||
- pnpm (package manager)
|
- [pnpm (package manager)](https://pnpm.io/installation)
|
||||||
|
|
||||||
### Development Setup
|
### Development Setup
|
||||||
|
|
||||||
|
|||||||
56
RELEASE_PROCEDURE.md
Normal file
56
RELEASE_PROCEDURE.md
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
1. Send a PR that updates the release notes "docs/content.en/docs/release-notes/_index.md", and
|
||||||
|
merge it into `main`.
|
||||||
|
|
||||||
|
2. Run release command (by @medcl)
|
||||||
|
|
||||||
|
Make sure you are on the latest main branch, then run `pnpm release`:
|
||||||
|
|
||||||
|
> NOTE: A tag is needed to trigger the [release CI][release_ci].
|
||||||
|
|
||||||
|
```sh
|
||||||
|
➜ coco-app git:(main) ✗ pnpm release
|
||||||
|
🚀 Let's release coco (currently at a.b.c)
|
||||||
|
|
||||||
|
Changelog:
|
||||||
|
* xxx
|
||||||
|
* xxx
|
||||||
|
|
||||||
|
✔ Select increment (next version):
|
||||||
|
|
||||||
|
Changeset:
|
||||||
|
M package.json
|
||||||
|
M src-tauri/Cargo.lock
|
||||||
|
M src-tauri/Cargo.toml
|
||||||
|
|
||||||
|
✔ Commit (vX.Y.Z)? Yes
|
||||||
|
✔ Tag (vX.Y.Z)? Yes
|
||||||
|
✔ Push? Yes
|
||||||
|
🏁 Done
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Build & Move Release Package
|
||||||
|
|
||||||
|
1. [Build][ci] the package for this release
|
||||||
|
2. @luohoufu moves the package to the stable folder.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
4. Update the [roadmap](https://coco.rs/en/roadmap) (if needed)
|
||||||
|
|
||||||
|
> You should update both English and Chinese JSON files
|
||||||
|
>
|
||||||
|
> * English: https://github.com/infinilabs/coco-website/blob/main/i18n/locales/en.json
|
||||||
|
> * Chinese: https://github.com/infinilabs/coco-website/blob/main/i18n/locales/zh.json
|
||||||
|
|
||||||
|
1. Add a new [section][roadmap_new] for the new release
|
||||||
|
2. Adjust the entries under [In Progress][in_prog] and [Up Next][up_next] accordingly
|
||||||
|
|
||||||
|
* Completed items should be removed from "In Progress"
|
||||||
|
* Some items should be moved from "Up Next" to "In Progress"
|
||||||
|
|
||||||
|
|
||||||
|
[release_ci]: https://github.com/infinilabs/coco-app/blob/main/.github/workflows/release.yml
|
||||||
|
[ci]: https://github.com/infinilabs/ci/actions/workflows/coco-app.yml
|
||||||
|
[roadmap_new]: https://github.com/infinilabs/coco-website/blob/5ae30bdfad0724bf27b4da8621b86be1dbe7bb8b/i18n/locales/en.json#L206-L218
|
||||||
|
[in_prog]: https://github.com/infinilabs/coco-website/blob/5ae30bdfad0724bf27b4da8621b86be1dbe7bb8b/i18n/locales/en.json#L121
|
||||||
|
[up_next]: https://github.com/infinilabs/coco-website/blob/5ae30bdfad0724bf27b4da8621b86be1dbe7bb8b/i18n/locales/en.json#L156
|
||||||
@@ -13,6 +13,12 @@ asciinema: true
|
|||||||
[x11_protocol]: https://en.wikipedia.org/wiki/X_Window_System
|
[x11_protocol]: https://en.wikipedia.org/wiki/X_Window_System
|
||||||
[if_x11]: https://unix.stackexchange.com/q/202891/498440
|
[if_x11]: https://unix.stackexchange.com/q/202891/498440
|
||||||
|
|
||||||
|
## Install dependencies
|
||||||
|
|
||||||
|
```sh
|
||||||
|
$ sudo apt-get update
|
||||||
|
$ sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf xdg-utils libtracker-sparql-3.0-dev
|
||||||
|
```
|
||||||
|
|
||||||
## Go to the download page
|
## Go to the download page
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,18 @@ title: "Release Notes"
|
|||||||
Information about release notes of Coco App is provided here.
|
Information about release notes of Coco App is provided here.
|
||||||
|
|
||||||
## Latest (In development)
|
## Latest (In development)
|
||||||
|
### ❌ Breaking changes
|
||||||
|
### 🚀 Features
|
||||||
|
### 🐛 Bug fix
|
||||||
|
### ✈️ Improvements
|
||||||
|
|
||||||
|
|
||||||
|
## 0.8.0 (2025-09-28)
|
||||||
|
|
||||||
### ❌ Breaking changes
|
### ❌ Breaking changes
|
||||||
|
|
||||||
|
- chore: update request accesstoken api #866
|
||||||
|
|
||||||
### 🚀 Features
|
### 🚀 Features
|
||||||
|
|
||||||
- feat: enhance ui for skipped version #834
|
- feat: enhance ui for skipped version #834
|
||||||
@@ -20,11 +29,24 @@ Information about release notes of Coco App is provided here.
|
|||||||
- feat: add extension uninstall option in settings #855
|
- feat: add extension uninstall option in settings #855
|
||||||
- feat: impl extension settings 'hide_before_open' #862
|
- feat: impl extension settings 'hide_before_open' #862
|
||||||
- feat: index both en/zh_CN app names and show app name in chosen language #875
|
- feat: index both en/zh_CN app names and show app name in chosen language #875
|
||||||
|
- feat: support context menu in debug mode #882
|
||||||
|
- feat: file search for Linux/GNOME #884
|
||||||
|
- feat: file search for Linux/KDE #886
|
||||||
|
- feat: extension Window Management for macOS #892
|
||||||
|
- feat: new extension type View #894
|
||||||
|
- feat: support opening file in its containing folder #900
|
||||||
|
|
||||||
### 🐛 Bug fix
|
### 🐛 Bug fix
|
||||||
|
|
||||||
- fix: fix issue with update check failure #833
|
- fix: fix issue with update check failure #833
|
||||||
- fix: web component login state #857
|
- fix: web component login state #857
|
||||||
|
- fix: shortcut key not opening extension store #877
|
||||||
|
- fix: set up hotkey on main thread or Windows will complain #879
|
||||||
|
- fix: resolve deeplink login issue #881
|
||||||
|
- fix: use kill_on_drop() to avoid zombie proc in error case #887
|
||||||
|
- fix: settings window rendering/loading issue 889
|
||||||
|
- fix: ensure search paths are indexed #896
|
||||||
|
- fix: bump applications-rs to fix empty app name issue #898
|
||||||
|
|
||||||
### ✈️ Improvements
|
### ✈️ Improvements
|
||||||
|
|
||||||
@@ -38,7 +60,11 @@ Information about release notes of Coco App is provided here.
|
|||||||
- build: web component build error #858
|
- build: web component build error #858
|
||||||
- refactor: coordinate third-party extension operations using lock #867
|
- refactor: coordinate third-party extension operations using lock #867
|
||||||
- refactor: index iOS apps and macOS apps that store icon in Assets.car #872
|
- refactor: index iOS apps and macOS apps that store icon in Assets.car #872
|
||||||
- refactor: accept both '-' and '_' as locale str separator #876
|
- refactor: accept both '-' and '\_' as locale str separator #876
|
||||||
|
- refactor: relax the file search conditions on macOS #883
|
||||||
|
- refactor: ensure Coco won't take focus #891
|
||||||
|
- chore: skip login check for web widget #895
|
||||||
|
- chore: convertFileSrc() "link[href]" and "img[src]" #901
|
||||||
|
|
||||||
## 0.7.1 (2025-07-27)
|
## 0.7.1 (2025-07-27)
|
||||||
|
|
||||||
|
|||||||
BIN
docs/static/img/release.png
vendored
Normal file
BIN
docs/static/img/release.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "coco",
|
"name": "coco",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.7.1",
|
"version": "0.8.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
"@tauri-apps/plugin-global-shortcut": "~2.0.0",
|
"@tauri-apps/plugin-global-shortcut": "~2.0.0",
|
||||||
"@tauri-apps/plugin-http": "~2.0.2",
|
"@tauri-apps/plugin-http": "~2.0.2",
|
||||||
"@tauri-apps/plugin-log": "~2.4.0",
|
"@tauri-apps/plugin-log": "~2.4.0",
|
||||||
"@tauri-apps/plugin-opener": "^2.2.7",
|
"@tauri-apps/plugin-opener": "^2.5.0",
|
||||||
"@tauri-apps/plugin-os": "^2.2.1",
|
"@tauri-apps/plugin-os": "^2.2.1",
|
||||||
"@tauri-apps/plugin-process": "^2.2.1",
|
"@tauri-apps/plugin-process": "^2.2.1",
|
||||||
"@tauri-apps/plugin-shell": "^2.2.1",
|
"@tauri-apps/plugin-shell": "^2.2.1",
|
||||||
@@ -93,4 +93,4 @@
|
|||||||
"vite": "^5.4.19"
|
"vite": "^5.4.19"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.11.0+sha512.6540583f41cc5f628eb3d9773ecee802f4f9ef9923cc45b69890fb47991d4b092964694ec3a4f738a420c918a333062c8b925d312f42e4f0c263eb603551f977"
|
"packageManager": "pnpm@10.11.0+sha512.6540583f41cc5f628eb3d9773ecee802f4f9ef9923cc45b69890fb47991d4b092964694ec3a4f738a420c918a333062c8b925d312f42e4f0c263eb603551f977"
|
||||||
}
|
}
|
||||||
|
|||||||
63
pnpm-lock.yaml
generated
63
pnpm-lock.yaml
generated
@@ -33,8 +33,8 @@ importers:
|
|||||||
specifier: ~2.4.0
|
specifier: ~2.4.0
|
||||||
version: 2.4.0
|
version: 2.4.0
|
||||||
'@tauri-apps/plugin-opener':
|
'@tauri-apps/plugin-opener':
|
||||||
specifier: ^2.2.7
|
specifier: ^2.5.0
|
||||||
version: 2.2.7
|
version: 2.5.0
|
||||||
'@tauri-apps/plugin-os':
|
'@tauri-apps/plugin-os':
|
||||||
specifier: ^2.2.1
|
specifier: ^2.2.1
|
||||||
version: 2.2.1
|
version: 2.2.1
|
||||||
@@ -790,6 +790,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
|
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
'@jridgewell/gen-mapping@0.3.13':
|
||||||
|
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
|
||||||
|
|
||||||
'@jridgewell/gen-mapping@0.3.8':
|
'@jridgewell/gen-mapping@0.3.8':
|
||||||
resolution: {integrity: sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==}
|
resolution: {integrity: sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==}
|
||||||
engines: {node: '>=6.0.0'}
|
engines: {node: '>=6.0.0'}
|
||||||
@@ -802,15 +805,21 @@ packages:
|
|||||||
resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==}
|
resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==}
|
||||||
engines: {node: '>=6.0.0'}
|
engines: {node: '>=6.0.0'}
|
||||||
|
|
||||||
'@jridgewell/source-map@0.3.6':
|
'@jridgewell/source-map@0.3.11':
|
||||||
resolution: {integrity: sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==}
|
resolution: {integrity: sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==}
|
||||||
|
|
||||||
'@jridgewell/sourcemap-codec@1.5.0':
|
'@jridgewell/sourcemap-codec@1.5.0':
|
||||||
resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==}
|
resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==}
|
||||||
|
|
||||||
|
'@jridgewell/sourcemap-codec@1.5.5':
|
||||||
|
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
|
||||||
|
|
||||||
'@jridgewell/trace-mapping@0.3.25':
|
'@jridgewell/trace-mapping@0.3.25':
|
||||||
resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==}
|
resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==}
|
||||||
|
|
||||||
|
'@jridgewell/trace-mapping@0.3.31':
|
||||||
|
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
||||||
|
|
||||||
'@mermaid-js/parser@0.4.0':
|
'@mermaid-js/parser@0.4.0':
|
||||||
resolution: {integrity: sha512-wla8XOWvQAwuqy+gxiZqY+c7FokraOTHRWMsbB4AgRx9Sy7zKslNyejy7E+a77qHfey5GXw/ik3IXv/NHMJgaA==}
|
resolution: {integrity: sha512-wla8XOWvQAwuqy+gxiZqY+c7FokraOTHRWMsbB4AgRx9Sy7zKslNyejy7E+a77qHfey5GXw/ik3IXv/NHMJgaA==}
|
||||||
|
|
||||||
@@ -1153,6 +1162,9 @@ packages:
|
|||||||
'@tauri-apps/api@2.5.0':
|
'@tauri-apps/api@2.5.0':
|
||||||
resolution: {integrity: sha512-Ldux4ip+HGAcPUmuLT8EIkk6yafl5vK0P0c0byzAKzxJh7vxelVtdPONjfgTm96PbN24yjZNESY8CKo8qniluA==}
|
resolution: {integrity: sha512-Ldux4ip+HGAcPUmuLT8EIkk6yafl5vK0P0c0byzAKzxJh7vxelVtdPONjfgTm96PbN24yjZNESY8CKo8qniluA==}
|
||||||
|
|
||||||
|
'@tauri-apps/api@2.8.0':
|
||||||
|
resolution: {integrity: sha512-ga7zdhbS2GXOMTIZRT0mYjKJtR9fivsXzsyq5U3vjDL0s6DTMwYRm0UHNjzTY5dh4+LSC68Sm/7WEiimbQNYlw==}
|
||||||
|
|
||||||
'@tauri-apps/cli-darwin-arm64@2.5.0':
|
'@tauri-apps/cli-darwin-arm64@2.5.0':
|
||||||
resolution: {integrity: sha512-VuVAeTFq86dfpoBDNYAdtQVLbP0+2EKCHIIhkaxjeoPARR0sLpFHz2zs0PcFU76e+KAaxtEtAJAXGNUc8E1PzQ==}
|
resolution: {integrity: sha512-VuVAeTFq86dfpoBDNYAdtQVLbP0+2EKCHIIhkaxjeoPARR0sLpFHz2zs0PcFU76e+KAaxtEtAJAXGNUc8E1PzQ==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
@@ -1242,8 +1254,8 @@ packages:
|
|||||||
'@tauri-apps/plugin-log@2.4.0':
|
'@tauri-apps/plugin-log@2.4.0':
|
||||||
resolution: {integrity: sha512-j7yrDtLNmayCBOO2esl3aZv9jSXy2an8MDLry3Ys9ZXerwUg35n1Y2uD8HoCR+8Ng/EUgx215+qOUfJasjYrHw==}
|
resolution: {integrity: sha512-j7yrDtLNmayCBOO2esl3aZv9jSXy2an8MDLry3Ys9ZXerwUg35n1Y2uD8HoCR+8Ng/EUgx215+qOUfJasjYrHw==}
|
||||||
|
|
||||||
'@tauri-apps/plugin-opener@2.2.7':
|
'@tauri-apps/plugin-opener@2.5.0':
|
||||||
resolution: {integrity: sha512-uduEyvOdjpPOEeDRrhwlCspG/f9EQalHumWBtLBnp3fRp++fKGLqDOyUhSIn7PzX45b/rKep//ZQSAQoIxobLA==}
|
resolution: {integrity: sha512-B0LShOYae4CZjN8leiNDbnfjSrTwoZakqKaWpfoH6nXiJwt6Rgj6RnVIffG3DoJiKsffRhMkjmBV9VeilSb4TA==}
|
||||||
|
|
||||||
'@tauri-apps/plugin-os@2.2.1':
|
'@tauri-apps/plugin-os@2.2.1':
|
||||||
resolution: {integrity: sha512-cNYpNri2CCc6BaNeB6G/mOtLvg8dFyFQyCUdf2y0K8PIAKGEWdEcu8DECkydU2B+oj4OJihDPD2de5K6cbVl9A==}
|
resolution: {integrity: sha512-cNYpNri2CCc6BaNeB6G/mOtLvg8dFyFQyCUdf2y0K8PIAKGEWdEcu8DECkydU2B+oj4OJihDPD2de5K6cbVl9A==}
|
||||||
@@ -1464,6 +1476,11 @@ packages:
|
|||||||
engines: {node: '>=0.4.0'}
|
engines: {node: '>=0.4.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
acorn@8.15.0:
|
||||||
|
resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==}
|
||||||
|
engines: {node: '>=0.4.0'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
agent-base@7.1.3:
|
agent-base@7.1.3:
|
||||||
resolution: {integrity: sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==}
|
resolution: {integrity: sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==}
|
||||||
engines: {node: '>= 14'}
|
engines: {node: '>= 14'}
|
||||||
@@ -4230,6 +4247,12 @@ snapshots:
|
|||||||
wrap-ansi: 8.1.0
|
wrap-ansi: 8.1.0
|
||||||
wrap-ansi-cjs: wrap-ansi@7.0.0
|
wrap-ansi-cjs: wrap-ansi@7.0.0
|
||||||
|
|
||||||
|
'@jridgewell/gen-mapping@0.3.13':
|
||||||
|
dependencies:
|
||||||
|
'@jridgewell/sourcemap-codec': 1.5.5
|
||||||
|
'@jridgewell/trace-mapping': 0.3.31
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@jridgewell/gen-mapping@0.3.8':
|
'@jridgewell/gen-mapping@0.3.8':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/set-array': 1.2.1
|
'@jridgewell/set-array': 1.2.1
|
||||||
@@ -4240,19 +4263,28 @@ snapshots:
|
|||||||
|
|
||||||
'@jridgewell/set-array@1.2.1': {}
|
'@jridgewell/set-array@1.2.1': {}
|
||||||
|
|
||||||
'@jridgewell/source-map@0.3.6':
|
'@jridgewell/source-map@0.3.11':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/gen-mapping': 0.3.8
|
'@jridgewell/gen-mapping': 0.3.13
|
||||||
'@jridgewell/trace-mapping': 0.3.25
|
'@jridgewell/trace-mapping': 0.3.31
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@jridgewell/sourcemap-codec@1.5.0': {}
|
'@jridgewell/sourcemap-codec@1.5.0': {}
|
||||||
|
|
||||||
|
'@jridgewell/sourcemap-codec@1.5.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@jridgewell/trace-mapping@0.3.25':
|
'@jridgewell/trace-mapping@0.3.25':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/resolve-uri': 3.1.2
|
'@jridgewell/resolve-uri': 3.1.2
|
||||||
'@jridgewell/sourcemap-codec': 1.5.0
|
'@jridgewell/sourcemap-codec': 1.5.0
|
||||||
|
|
||||||
|
'@jridgewell/trace-mapping@0.3.31':
|
||||||
|
dependencies:
|
||||||
|
'@jridgewell/resolve-uri': 3.1.2
|
||||||
|
'@jridgewell/sourcemap-codec': 1.5.5
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@mermaid-js/parser@0.4.0':
|
'@mermaid-js/parser@0.4.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
langium: 3.3.1
|
langium: 3.3.1
|
||||||
@@ -4546,6 +4578,8 @@ snapshots:
|
|||||||
|
|
||||||
'@tauri-apps/api@2.5.0': {}
|
'@tauri-apps/api@2.5.0': {}
|
||||||
|
|
||||||
|
'@tauri-apps/api@2.8.0': {}
|
||||||
|
|
||||||
'@tauri-apps/cli-darwin-arm64@2.5.0':
|
'@tauri-apps/cli-darwin-arm64@2.5.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -4617,9 +4651,9 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@tauri-apps/api': 2.5.0
|
'@tauri-apps/api': 2.5.0
|
||||||
|
|
||||||
'@tauri-apps/plugin-opener@2.2.7':
|
'@tauri-apps/plugin-opener@2.5.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tauri-apps/api': 2.5.0
|
'@tauri-apps/api': 2.8.0
|
||||||
|
|
||||||
'@tauri-apps/plugin-os@2.2.1':
|
'@tauri-apps/plugin-os@2.2.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -4876,6 +4910,9 @@ snapshots:
|
|||||||
|
|
||||||
acorn@8.14.1: {}
|
acorn@8.14.1: {}
|
||||||
|
|
||||||
|
acorn@8.15.0:
|
||||||
|
optional: true
|
||||||
|
|
||||||
agent-base@7.1.3: {}
|
agent-base@7.1.3: {}
|
||||||
|
|
||||||
ahooks@3.8.4(react@18.3.1):
|
ahooks@3.8.4(react@18.3.1):
|
||||||
@@ -7232,8 +7269,8 @@ snapshots:
|
|||||||
|
|
||||||
terser@5.40.0:
|
terser@5.40.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/source-map': 0.3.6
|
'@jridgewell/source-map': 0.3.11
|
||||||
acorn: 8.14.1
|
acorn: 8.15.0
|
||||||
commander: 2.20.3
|
commander: 2.20.3
|
||||||
source-map-support: 0.5.21
|
source-map-support: 0.5.21
|
||||||
optional: true
|
optional: true
|
||||||
|
|||||||
2805
src-tauri/Cargo.lock
generated
2805
src-tauri/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "coco"
|
name = "coco"
|
||||||
version = "0.7.1"
|
version = "0.8.0"
|
||||||
description = "Search, connect, collaborate – all in one place."
|
description = "Search, connect, collaborate – all in one place."
|
||||||
authors = ["INFINI Labs"]
|
authors = ["INFINI Labs"]
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
@@ -15,6 +15,7 @@ crate-type = ["staticlib", "cdylib", "rlib"]
|
|||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
tauri-build = { version = "2", features = ["default"] }
|
tauri-build = { version = "2", features = ["default"] }
|
||||||
|
cfg-if = "1.0.1"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["desktop"]
|
default = ["desktop"]
|
||||||
@@ -61,7 +62,7 @@ tauri-plugin-drag = "2"
|
|||||||
tauri-plugin-macos-permissions = "2"
|
tauri-plugin-macos-permissions = "2"
|
||||||
tauri-plugin-fs-pro = "2"
|
tauri-plugin-fs-pro = "2"
|
||||||
tauri-plugin-screenshots = "2"
|
tauri-plugin-screenshots = "2"
|
||||||
applications = { git = "https://github.com/infinilabs/applications-rs", rev = "2f1f88d1880404c5f8d70ad950b859bd49922bee" }
|
applications = { git = "https://github.com/infinilabs/applications-rs", rev = "b5fac4034a40d42e72f727f1aa1cc1f19fe86653" }
|
||||||
tokio-native-tls = "0.3" # For wss connections
|
tokio-native-tls = "0.3" # For wss connections
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
tokio-tungstenite = { version = "0.20", features = ["native-tls"] }
|
tokio-tungstenite = { version = "0.20", features = ["native-tls"] }
|
||||||
@@ -103,17 +104,42 @@ zip = "4.0.0"
|
|||||||
url = "2.5.2"
|
url = "2.5.2"
|
||||||
camino = "1.1.10"
|
camino = "1.1.10"
|
||||||
tokio-stream = { version = "0.1.17", features = ["io-util"] }
|
tokio-stream = { version = "0.1.17", features = ["io-util"] }
|
||||||
cfg-if = "1.0.1"
|
|
||||||
sysinfo = "0.35.2"
|
sysinfo = "0.35.2"
|
||||||
indexmap = { version = "2.10.0", features = ["serde"] }
|
indexmap = { version = "2.10.0", features = ["serde"] }
|
||||||
strum = { version = "0.27.2", features = ["derive"] }
|
strum = { version = "0.27.2", features = ["derive"] }
|
||||||
sys-locale = "0.3.2"
|
sys-locale = "0.3.2"
|
||||||
|
tauri-plugin-prevent-default = "1"
|
||||||
|
oneshot = "0.1.11"
|
||||||
|
bitflags = "2.9.3"
|
||||||
|
cfg-if = "1.0.1"
|
||||||
|
dunce = "1.0.5"
|
||||||
|
urlencoding = "2.1.3"
|
||||||
|
scraper = "0.17"
|
||||||
|
toml = "0.8"
|
||||||
|
path-clean = "1.0.1"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = "3.23.0"
|
||||||
|
|
||||||
[target."cfg(target_os = \"macos\")".dependencies]
|
[target."cfg(target_os = \"macos\")".dependencies]
|
||||||
tauri-nspanel = { git = "https://github.com/ahkohd/tauri-nspanel", branch = "v2" }
|
tauri-nspanel = { git = "https://github.com/ahkohd/tauri-nspanel", branch = "v2" }
|
||||||
|
objc2-app-kit = { version = "0.3.1", features = ["NSWindow"] }
|
||||||
|
objc2 = "0.6.2"
|
||||||
|
objc2-core-foundation = {version = "0.3.1", features = ["CFString", "CFCGTypes", "CFArray"] }
|
||||||
|
objc2-application-services = { version = "0.3.1", features = ["HIServices"] }
|
||||||
|
objc2-core-graphics = { version = "=0.3.1", features = ["CGEvent"] }
|
||||||
|
|
||||||
|
[target."cfg(target_os = \"linux\")".dependencies]
|
||||||
|
gio = "0.21.2"
|
||||||
|
glib = "0.21.2"
|
||||||
|
tracker-rs = "0.7"
|
||||||
|
which = "8.0.0"
|
||||||
|
configparser = "3.1.0"
|
||||||
|
|
||||||
[target."cfg(any(target_os = \"macos\", windows, target_os = \"linux\"))".dependencies]
|
[target."cfg(any(target_os = \"macos\", windows, target_os = \"linux\"))".dependencies]
|
||||||
tauri-plugin-single-instance = { version = "2.0.0", features = ["deep-link"] }
|
tauri-plugin-single-instance = { version = "2.0.0", features = ["deep-link"] }
|
||||||
|
serde = { version = "1.0.219", features = ["derive"], optional = true }
|
||||||
|
|
||||||
|
|
||||||
[profile.dev]
|
[profile.dev]
|
||||||
incremental = true # Compile your binary in smaller steps.
|
incremental = true # Compile your binary in smaller steps.
|
||||||
@@ -134,4 +160,8 @@ semver = { version = "1", features = ["serde"] }
|
|||||||
|
|
||||||
[target."cfg(target_os = \"windows\")".dependencies]
|
[target."cfg(target_os = \"windows\")".dependencies]
|
||||||
enigo="0.3"
|
enigo="0.3"
|
||||||
windows = { version = "0.61.3", features = ["Win32_Foundation", "Win32_System_Com", "Win32_System_Ole", "Win32_System_Search", "Win32_UI_Shell_PropertiesSystem", "Win32_Data"] }
|
windows = { version = "0.61", features = ["Win32_Foundation", "Win32_System_Com", "Win32_System_Ole", "Win32_System_Search", "Win32_UI_Shell_PropertiesSystem", "Win32_Data"] }
|
||||||
|
windows-sys = { version = "0.61", features = ["Win32", "Win32_System", "Win32_System_Com"] }
|
||||||
|
|
||||||
|
[target."cfg(target_os = \"windows\")".build-dependencies]
|
||||||
|
bindgen = "0.72.1"
|
||||||
|
|||||||
@@ -11,4 +11,32 @@ fn main() {
|
|||||||
//
|
//
|
||||||
// unexpected condition name: `ci`
|
// unexpected condition name: `ci`
|
||||||
println!("cargo::rustc-check-cfg=cfg(ci)");
|
println!("cargo::rustc-check-cfg=cfg(ci)");
|
||||||
|
|
||||||
|
// Bindgen searchapi.h on Windows as the windows create does not provide
|
||||||
|
// bindings for it
|
||||||
|
cfg_if::cfg_if! {
|
||||||
|
if #[cfg(target_os = "windows")] {
|
||||||
|
use std::env;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
let wrapper_header = r#"#include <windows.h>
|
||||||
|
#include <searchapi.h>"#;
|
||||||
|
|
||||||
|
let searchapi_bindings = bindgen::Builder::default()
|
||||||
|
.header_contents("wrapper.h", wrapper_header)
|
||||||
|
.generate()
|
||||||
|
.expect("failed to generate bindings for <searchapi.h>");
|
||||||
|
|
||||||
|
let out_path = PathBuf::from(env::var("OUT_DIR").unwrap());
|
||||||
|
searchapi_bindings
|
||||||
|
.write_to_file(out_path.join("searchapi_bindings.rs"))
|
||||||
|
.expect("couldn't write bindings to <OUT_DIR/searchapi_bindings.rs>")
|
||||||
|
|
||||||
|
// Looks like there is no need to link the library that contains the
|
||||||
|
// implementation of functions declared in 'searchapi.h' manually as
|
||||||
|
// the FFI bindings work (without doing that).
|
||||||
|
//
|
||||||
|
// This is wield, I do not expect the linker will link it automatically.
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
use crate::extension::ExtensionSettings;
|
#[cfg(target_os = "macos")]
|
||||||
|
use crate::extension::built_in::window_management::actions::Action;
|
||||||
|
use crate::extension::{ExtensionPermission, ExtensionSettings};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use tauri::AppHandle;
|
use tauri::{AppHandle, Emitter};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct RichLabel {
|
pub struct RichLabel {
|
||||||
@@ -43,6 +45,9 @@ pub(crate) enum OnOpened {
|
|||||||
Application { app_path: String },
|
Application { app_path: String },
|
||||||
/// Open the URL.
|
/// Open the URL.
|
||||||
Document { url: String },
|
Document { url: String },
|
||||||
|
/// Perform this WM action.
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
WindowManagementAction { action: Action },
|
||||||
/// The document is an extension.
|
/// The document is an extension.
|
||||||
Extension(ExtensionOnOpened),
|
Extension(ExtensionOnOpened),
|
||||||
}
|
}
|
||||||
@@ -55,6 +60,11 @@ pub(crate) struct ExtensionOnOpened {
|
|||||||
///
|
///
|
||||||
/// Optional because not all extensions have their settings.
|
/// Optional because not all extensions have their settings.
|
||||||
pub(crate) settings: Option<ExtensionSettings>,
|
pub(crate) settings: Option<ExtensionSettings>,
|
||||||
|
/// Permission needed by this extension.
|
||||||
|
///
|
||||||
|
/// We do permission check when opening this permission. Currently, we only
|
||||||
|
/// do this to View extensions.
|
||||||
|
pub(crate) permission: Option<ExtensionPermission>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -74,6 +84,12 @@ pub(crate) enum ExtensionOnOpenedType {
|
|||||||
link: crate::extension::QuicklinkLink,
|
link: crate::extension::QuicklinkLink,
|
||||||
open_with: Option<String>,
|
open_with: Option<String>,
|
||||||
},
|
},
|
||||||
|
View {
|
||||||
|
/// Path to the HTML file that coco will load and render.
|
||||||
|
///
|
||||||
|
/// It should be an absolute path or Tauri cannot open it.
|
||||||
|
page: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
impl OnOpened {
|
impl OnOpened {
|
||||||
@@ -81,6 +97,11 @@ impl OnOpened {
|
|||||||
match self {
|
match self {
|
||||||
Self::Application { app_path } => app_path.clone(),
|
Self::Application { app_path } => app_path.clone(),
|
||||||
Self::Document { url } => url.clone(),
|
Self::Document { url } => url.clone(),
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
Self::WindowManagementAction { action: _ } => {
|
||||||
|
// We don't have URL for this
|
||||||
|
String::from("N/A")
|
||||||
|
}
|
||||||
Self::Extension(ext_on_opened) => {
|
Self::Extension(ext_on_opened) => {
|
||||||
match &ext_on_opened.ty {
|
match &ext_on_opened.ty {
|
||||||
ExtensionOnOpenedType::Command { action } => {
|
ExtensionOnOpenedType::Command { action } => {
|
||||||
@@ -97,6 +118,10 @@ impl OnOpened {
|
|||||||
// The URL of a quicklink is nearly useless without such dynamic user
|
// The URL of a quicklink is nearly useless without such dynamic user
|
||||||
// inputs, so until we have dynamic URL support, we just use "N/A".
|
// inputs, so until we have dynamic URL support, we just use "N/A".
|
||||||
ExtensionOnOpenedType::Quicklink { .. } => String::from("N/A"),
|
ExtensionOnOpenedType::Quicklink { .. } => String::from("N/A"),
|
||||||
|
ExtensionOnOpenedType::View { page: _ } => {
|
||||||
|
// We currently don't have URL for this kind of extension.
|
||||||
|
String::from("N/A")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -123,6 +148,15 @@ pub(crate) async fn open(
|
|||||||
|
|
||||||
homemade_tauri_shell_open(tauri_app_handle.clone(), url).await?
|
homemade_tauri_shell_open(tauri_app_handle.clone(), url).await?
|
||||||
}
|
}
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
OnOpened::WindowManagementAction { action } => {
|
||||||
|
log::debug!("perform Window Management action [{:?}]", action);
|
||||||
|
|
||||||
|
crate::extension::built_in::window_management::perform_action_on_main_thread(
|
||||||
|
&tauri_app_handle,
|
||||||
|
action,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
OnOpened::Extension(ext_on_opened) => {
|
OnOpened::Extension(ext_on_opened) => {
|
||||||
// Apply the settings that would affect open behavior
|
// Apply the settings that would affect open behavior
|
||||||
if let Some(settings) = ext_on_opened.settings {
|
if let Some(settings) = ext_on_opened.settings {
|
||||||
@@ -132,6 +166,7 @@ pub(crate) async fn open(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let permission = ext_on_opened.permission;
|
||||||
|
|
||||||
match ext_on_opened.ty {
|
match ext_on_opened.ty {
|
||||||
ExtensionOnOpenedType::Command { action } => {
|
ExtensionOnOpenedType::Command { action } => {
|
||||||
@@ -196,6 +231,24 @@ pub(crate) async fn open(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
ExtensionOnOpenedType::View { page } => {
|
||||||
|
/*
|
||||||
|
* Emit an event to let the frontend code open this extension.
|
||||||
|
*
|
||||||
|
* Payload `page_and_permission` contains the information needed
|
||||||
|
* to do that.
|
||||||
|
*
|
||||||
|
* See "src/pages/main/index.tsx" for more info.
|
||||||
|
*/
|
||||||
|
use serde_json::Value as Json;
|
||||||
|
use serde_json::to_value;
|
||||||
|
|
||||||
|
let page_and_permission: [Json; 2] =
|
||||||
|
[Json::String(page), to_value(permission).unwrap()];
|
||||||
|
tauri_app_handle
|
||||||
|
.emit("open_view_extension", page_and_permission)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
5
src-tauri/src/extension/api/apis.toml
Normal file
5
src-tauri/src/extension/api/apis.toml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Complete Coco extension API list grouped by its category.
|
||||||
|
|
||||||
|
fs = [
|
||||||
|
"read_dir"
|
||||||
|
]
|
||||||
22
src-tauri/src/extension/api/fs.rs
Normal file
22
src-tauri/src/extension/api/fs.rs
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
//! File system APIs
|
||||||
|
|
||||||
|
use tokio::fs::read_dir as tokio_read_dir;
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub(crate) async fn read_dir(path: String) -> Result<Vec<String>, String> {
|
||||||
|
let mut iter = tokio_read_dir(path).await.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let mut file_names = Vec::new();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let opt_entry = iter.next_entry().await.map_err(|e| e.to_string())?;
|
||||||
|
let Some(entry) = opt_entry else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
|
||||||
|
let file_name = entry.file_name().to_string_lossy().into_owned();
|
||||||
|
file_names.push(file_name);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(file_names)
|
||||||
|
}
|
||||||
21
src-tauri/src/extension/api/mod.rs
Normal file
21
src-tauri/src/extension/api/mod.rs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
//! The Rust implementation of the Coco extension APIs.
|
||||||
|
//!
|
||||||
|
//! Extension developers do not use these Rust APIs directly, they use our
|
||||||
|
//! [Typescript library][ts_lib], which eventually calls these APIs.
|
||||||
|
//!
|
||||||
|
//! [ts_lib]: https://github.com/infinilabs/coco-api
|
||||||
|
|
||||||
|
pub(crate) mod fs;
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
/// Return all the available APIs grouped by their category.
|
||||||
|
#[tauri::command]
|
||||||
|
pub(crate) fn apis() -> HashMap<String, Vec<String>> {
|
||||||
|
static APIS_TOML: &str = include_str!("./apis.toml");
|
||||||
|
|
||||||
|
let apis: HashMap<String, Vec<String>> =
|
||||||
|
toml::from_str(APIS_TOML).expect("Failed to parse apis.toml file");
|
||||||
|
|
||||||
|
apis
|
||||||
|
}
|
||||||
@@ -1235,11 +1235,14 @@ pub async fn get_app_list(tauri_app_handle: AppHandle) -> Result<Vec<Extension>,
|
|||||||
quicklink: None,
|
quicklink: None,
|
||||||
commands: None,
|
commands: None,
|
||||||
scripts: None,
|
scripts: None,
|
||||||
|
views: None,
|
||||||
quicklinks: None,
|
quicklinks: None,
|
||||||
alias: Some(alias),
|
alias: Some(alias),
|
||||||
hotkey,
|
hotkey,
|
||||||
enabled,
|
enabled,
|
||||||
settings: None,
|
settings: None,
|
||||||
|
page: None,
|
||||||
|
permission: None,
|
||||||
screenshots: None,
|
screenshots: None,
|
||||||
url: None,
|
url: None,
|
||||||
version: None,
|
version: None,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
//! File Search configuration entries definition and getter/setter functions.
|
//! File Search configuration entries definition and getter/setter functions.
|
||||||
|
|
||||||
|
use crate::extension::built_in::file_search::implementation::apply_config;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
@@ -23,7 +24,7 @@ static HOME_DIR: LazyLock<String> = LazyLock::new(|| {
|
|||||||
.expect("User home directory should be encoded with UTF-8")
|
.expect("User home directory should be encoded with UTF-8")
|
||||||
});
|
});
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Copy)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Copy, PartialEq)]
|
||||||
pub enum SearchBy {
|
pub enum SearchBy {
|
||||||
Name,
|
Name,
|
||||||
NameAndContents,
|
NameAndContents,
|
||||||
@@ -197,13 +198,19 @@ pub async fn set_file_system_config(
|
|||||||
.store(TAURI_STORE_FILE_SYSTEM_CONFIG)
|
.store(TAURI_STORE_FILE_SYSTEM_CONFIG)
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
store.set(TAURI_STORE_KEY_SEARCH_PATHS, config.search_paths);
|
store.set(TAURI_STORE_KEY_SEARCH_PATHS, config.search_paths.as_slice());
|
||||||
store.set(TAURI_STORE_KEY_EXCLUDE_PATHS, config.exclude_paths);
|
store.set(
|
||||||
store.set(TAURI_STORE_KEY_FILE_TYPES, config.file_types);
|
TAURI_STORE_KEY_EXCLUDE_PATHS,
|
||||||
|
config.exclude_paths.as_slice(),
|
||||||
|
);
|
||||||
|
store.set(TAURI_STORE_KEY_FILE_TYPES, config.file_types.as_slice());
|
||||||
store.set(
|
store.set(
|
||||||
TAURI_STORE_KEY_SEARCH_BY,
|
TAURI_STORE_KEY_SEARCH_BY,
|
||||||
serde_json::to_value(config.search_by).unwrap(),
|
serde_json::to_value(config.search_by).unwrap(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Apply the config when we know that this set operation won't fail
|
||||||
|
apply_config(&config)?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,388 @@
|
|||||||
|
//! File system powered by GNOME's Tracker engine.
|
||||||
|
|
||||||
|
use super::super::super::EXTENSION_ID;
|
||||||
|
use super::super::super::config::FileSearchConfig;
|
||||||
|
use super::super::should_be_filtered_out;
|
||||||
|
use crate::common::document::DataSourceReference;
|
||||||
|
use crate::extension::LOCAL_QUERY_SOURCE_TYPE;
|
||||||
|
use crate::util::file::sync_get_file_icon;
|
||||||
|
use crate::{
|
||||||
|
common::document::{Document, OnOpened},
|
||||||
|
extension::built_in::file_search::config::SearchBy,
|
||||||
|
};
|
||||||
|
use camino::Utf8Path;
|
||||||
|
use gio::Cancellable;
|
||||||
|
use gio::Settings;
|
||||||
|
use gio::prelude::SettingsExtManual;
|
||||||
|
use glib::GString;
|
||||||
|
use glib::collections::strv::StrV;
|
||||||
|
use tracker::{SparqlConnection, SparqlCursor, prelude::SparqlCursorExtManual};
|
||||||
|
|
||||||
|
/// The service that we will connect to.
|
||||||
|
const SERVICE_NAME: &str = "org.freedesktop.Tracker3.Miner.Files";
|
||||||
|
|
||||||
|
/// Tracker won't return scores when we are not using full-text seach. In that
|
||||||
|
/// case, we use this score.
|
||||||
|
const SCORE: f64 = 1.0;
|
||||||
|
|
||||||
|
/// Helper function to return different SPARQL queries depending on the different configurations.
|
||||||
|
fn query_sparql(query_string: &str, config: &FileSearchConfig) -> String {
|
||||||
|
match config.search_by {
|
||||||
|
SearchBy::Name => {
|
||||||
|
// Cannot use the inverted index as that searches for all the attributes,
|
||||||
|
// but we only want to search the filename.
|
||||||
|
format!(
|
||||||
|
"SELECT nie:url(?file_item) WHERE {{ ?file_item nfo:fileName ?fileName . FILTER(regex(?fileName, '{query_string}', 'i')) }}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
SearchBy::NameAndContents => {
|
||||||
|
// Full-text search against all attributes
|
||||||
|
// OR
|
||||||
|
// filename search
|
||||||
|
format!(
|
||||||
|
"SELECT nie:url(?file_item) fts:rank(?file_item) WHERE {{ {{ ?file_item fts:match '{query_string}' }} UNION {{ ?file_item nfo:fileName ?fileName . FILTER(regex(?fileName, '{query_string}', 'i')) }} }} ORDER BY DESC fts:rank(?file_item)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper function to replace unsupported characters with whitespace.
|
||||||
|
///
|
||||||
|
/// Tracker will error out if it encounters these characters.
|
||||||
|
///
|
||||||
|
/// The complete list of unsupported characters is unknown and we don't know how
|
||||||
|
/// to escape them, so let's replace them.
|
||||||
|
fn query_string_cleanup(old: &str) -> String {
|
||||||
|
const UNSUPPORTED_CHAR: [char; 3] = ['\'', '\n', '\\'];
|
||||||
|
|
||||||
|
// Using len in bytes is ok
|
||||||
|
let mut chars = Vec::with_capacity(old.len());
|
||||||
|
for char in old.chars() {
|
||||||
|
if UNSUPPORTED_CHAR.contains(&char) {
|
||||||
|
chars.push(' ');
|
||||||
|
} else {
|
||||||
|
chars.push(char);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
chars.into_iter().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Query {
|
||||||
|
conn: SparqlConnection,
|
||||||
|
cursor: SparqlCursor,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Query {
|
||||||
|
fn new(query_string: &str, config: &FileSearchConfig) -> Result<Self, String> {
|
||||||
|
let query_string = query_string_cleanup(query_string);
|
||||||
|
let sparql = query_sparql(&query_string, config);
|
||||||
|
let conn =
|
||||||
|
SparqlConnection::bus_new(SERVICE_NAME, None, None).map_err(|e| e.to_string())?;
|
||||||
|
let cursor = conn
|
||||||
|
.query(&sparql, Cancellable::NONE)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
Ok(Self { conn, cursor })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for Query {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.cursor.close();
|
||||||
|
self.conn.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Iterator for Query {
|
||||||
|
/// It yields a tuple `(file path, score)`
|
||||||
|
type Item = Result<(String, f64), String>;
|
||||||
|
|
||||||
|
fn next(&mut self) -> Option<Self::Item> {
|
||||||
|
loop {
|
||||||
|
let has_next = match self
|
||||||
|
.cursor
|
||||||
|
.next(Cancellable::NONE)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
{
|
||||||
|
Ok(has_next) => has_next,
|
||||||
|
Err(err_str) => return Some(Err(err_str)),
|
||||||
|
};
|
||||||
|
|
||||||
|
if !has_next {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The first column is the URL
|
||||||
|
let file_url_column = self.cursor.string(0);
|
||||||
|
// It could be None (or NULL ptr if you use C), I have no clue why.
|
||||||
|
let opt_str = file_url_column.as_ref().map(|gstr| gstr.as_str());
|
||||||
|
|
||||||
|
match opt_str {
|
||||||
|
Some(url) => {
|
||||||
|
// The returned URL has a prefix that we need to trim
|
||||||
|
const PREFIX: &str = "file://";
|
||||||
|
const PREFIX_LEN: usize = PREFIX.len();
|
||||||
|
|
||||||
|
let file_path = url[PREFIX_LEN..].to_string();
|
||||||
|
assert!(!file_path.is_empty());
|
||||||
|
assert_ne!(file_path, "/", "file search should not hit the root path");
|
||||||
|
|
||||||
|
let score = {
|
||||||
|
// The second column is the score, this column may not
|
||||||
|
// exist. We use SCORE if the real value is absent.
|
||||||
|
let score_column = self.cursor.string(1);
|
||||||
|
let opt_score_str = score_column.as_ref().map(|g_str| g_str.as_str());
|
||||||
|
let opt_score = opt_score_str.map(|str| {
|
||||||
|
str.parse::<f64>()
|
||||||
|
.expect("score should be valid for type f64")
|
||||||
|
});
|
||||||
|
|
||||||
|
opt_score.unwrap_or(SCORE)
|
||||||
|
};
|
||||||
|
|
||||||
|
return Some(Ok((file_path, score)));
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
// another try
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn hits(
|
||||||
|
query_string: &str,
|
||||||
|
from: usize,
|
||||||
|
size: usize,
|
||||||
|
config: &FileSearchConfig,
|
||||||
|
) -> Result<Vec<(Document, f64)>, String> {
|
||||||
|
// Special cases that will make querying faster.
|
||||||
|
if query_string.is_empty() || size == 0 || config.search_paths.is_empty() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut result_hits = Vec::with_capacity(size);
|
||||||
|
|
||||||
|
let need_to_skip = {
|
||||||
|
if matches!(config.search_by, SearchBy::Name) {
|
||||||
|
// We don't use full-text search in this case, the returned documents
|
||||||
|
// won't be scored, the query hits won't be sorted, so processing the
|
||||||
|
// from parameter is meaningless.
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
from > 0
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut num_skipped = 0;
|
||||||
|
let should_skip = from;
|
||||||
|
|
||||||
|
let query = Query::new(query_string, config)?;
|
||||||
|
for res_entry in query {
|
||||||
|
let (file_path, score) = res_entry?;
|
||||||
|
|
||||||
|
// This should be called before processing the `from` parameter.
|
||||||
|
if should_be_filtered_out(config, &file_path, true, true, true) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process the `from` parameter.
|
||||||
|
if need_to_skip && num_skipped < should_skip {
|
||||||
|
// Skip this
|
||||||
|
num_skipped += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let icon = sync_get_file_icon(&file_path);
|
||||||
|
let file_path_of_type_path = camino::Utf8Path::new(&file_path);
|
||||||
|
let r#where = file_path_of_type_path
|
||||||
|
.parent()
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
panic!(
|
||||||
|
"expect path [{}] to have a parent, but it does not",
|
||||||
|
file_path
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let file_name = file_path_of_type_path.file_name().unwrap_or_else(|| {
|
||||||
|
panic!(
|
||||||
|
"expect path [{}] to have a file name, but it does not",
|
||||||
|
file_path
|
||||||
|
);
|
||||||
|
});
|
||||||
|
let on_opened = OnOpened::Document {
|
||||||
|
url: file_path.to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let doc = Document {
|
||||||
|
id: file_path.to_string(),
|
||||||
|
title: Some(file_name.to_string()),
|
||||||
|
source: Some(DataSourceReference {
|
||||||
|
r#type: Some(LOCAL_QUERY_SOURCE_TYPE.into()),
|
||||||
|
name: Some(EXTENSION_ID.into()),
|
||||||
|
id: Some(EXTENSION_ID.into()),
|
||||||
|
icon: Some(String::from("font_Filesearch")),
|
||||||
|
}),
|
||||||
|
category: Some(r#where),
|
||||||
|
on_opened: Some(on_opened),
|
||||||
|
url: Some(file_path),
|
||||||
|
icon: Some(icon.to_string()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
result_hits.push((doc, score));
|
||||||
|
|
||||||
|
// Collected enough documents, return
|
||||||
|
if result_hits.len() >= size {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(result_hits)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_path_in_recursive_indexing_scope(list: &mut StrV, path: &str) {
|
||||||
|
for item in list.iter() {
|
||||||
|
let item_path = Utf8Path::new(item.as_str());
|
||||||
|
let path = Utf8Path::new(path);
|
||||||
|
|
||||||
|
// It is already covered or listed
|
||||||
|
if path.starts_with(item_path) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
list.push(
|
||||||
|
GString::from_utf8_checked(path.as_bytes().to_vec())
|
||||||
|
.expect("search_path_str contains an interior NUL"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_path_and_descendants_not_in_single_indexing_scope(list: &mut StrV, path: &str) {
|
||||||
|
// Indexes to the items that should be removed
|
||||||
|
let mut item_to_remove = Vec::new();
|
||||||
|
for (idx, item) in list.iter().enumerate() {
|
||||||
|
let item_path = Utf8Path::new(item.as_str());
|
||||||
|
let path = Utf8Path::new(path);
|
||||||
|
|
||||||
|
if item_path.starts_with(path) {
|
||||||
|
item_to_remove.push(idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reverse the indexes so that the remove operation won't invalidate them.
|
||||||
|
for idx in item_to_remove.into_iter().rev() {
|
||||||
|
list.remove(idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn apply_config(config: &FileSearchConfig) -> Result<(), String> {
|
||||||
|
// Tracker provides the following configuration entries to allow users to
|
||||||
|
// tweak the indexing scope:
|
||||||
|
//
|
||||||
|
// 1. ignored-directories: A list of names, directories with such names will be ignored.
|
||||||
|
// ['po', 'CVS', 'core-dumps', 'lost+found']
|
||||||
|
// 2. ignored-directories-with-content: Avoid any directory containing a file blocklisted here
|
||||||
|
// ['.trackerignore', '.git', '.hg', '.nomedia']
|
||||||
|
// 3. ignored-files: List of file patterns to avoid
|
||||||
|
// ['*~', '*.o', '*.la', '*.lo', '*.loT', '*.in', '*.m4', '*.rej', ...]
|
||||||
|
// 4. index-recursive-directories: List of directories to index recursively
|
||||||
|
// ['&DESKTOP', '&DOCUMENTS', '&MUSIC', '&PICTURES', '&VIDEOS']
|
||||||
|
// 5. index-single-directories: List of directories to index without inspecting subfolders,
|
||||||
|
// ['$HOME', '&DOWNLOAD']
|
||||||
|
//
|
||||||
|
// The first 3 entries specify patterns, in order to use them, we have to walk
|
||||||
|
// through the whole directory tree listed in search paths, which is impractical.
|
||||||
|
// So we only use the last 2 entries.
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// Just want to mention that setting search path to "/home" could break Tracker:
|
||||||
|
//
|
||||||
|
// ```text
|
||||||
|
// Unknown target graph for uri:'file:///home' and mime:'inode/directory'
|
||||||
|
// ```
|
||||||
|
//
|
||||||
|
// See the related bug reports:
|
||||||
|
//
|
||||||
|
// https://gitlab.gnome.org/GNOME/localsearch/-/issues/313
|
||||||
|
// https://bugs.launchpad.net/bugs/2077181
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// There is nothing we can do.
|
||||||
|
|
||||||
|
const TRACKER_SETTINGS_SCHEMA: &str = "org.freedesktop.Tracker3.Miner.Files";
|
||||||
|
const KEY_INDEX_RECURSIVE_DIRECTORIES: &str = "index-recursive-directories";
|
||||||
|
const KEY_INDEX_SINGLE_DIRECTORIES: &str = "index-single-directories";
|
||||||
|
|
||||||
|
let search_paths = &config.search_paths;
|
||||||
|
|
||||||
|
let settings = Settings::new(TRACKER_SETTINGS_SCHEMA);
|
||||||
|
let mut recursive_list: StrV = settings.strv(KEY_INDEX_RECURSIVE_DIRECTORIES);
|
||||||
|
let mut single_list: StrV = settings.strv(KEY_INDEX_SINGLE_DIRECTORIES);
|
||||||
|
|
||||||
|
for search_path in search_paths {
|
||||||
|
// We want our search path to be included in the recursive directories or
|
||||||
|
// any directory within the list covers it.
|
||||||
|
ensure_path_in_recursive_indexing_scope(&mut recursive_list, search_path);
|
||||||
|
// We want our search path and its any descendants are not listed in
|
||||||
|
// the index directories list.
|
||||||
|
ensure_path_and_descendants_not_in_single_indexing_scope(&mut single_list, search_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
settings
|
||||||
|
.set_strv(KEY_INDEX_RECURSIVE_DIRECTORIES, recursive_list)
|
||||||
|
.expect("key is not read-only");
|
||||||
|
settings
|
||||||
|
.set_strv(KEY_INDEX_SINGLE_DIRECTORIES, single_list)
|
||||||
|
.expect("key is not be read-only");
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_query_string_cleanup_basic() {
|
||||||
|
assert_eq!(query_string_cleanup("test"), "test");
|
||||||
|
assert_eq!(query_string_cleanup("hello world"), "hello world");
|
||||||
|
assert_eq!(query_string_cleanup("file.txt"), "file.txt");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_query_string_cleanup_unsupported_chars() {
|
||||||
|
assert_eq!(query_string_cleanup("test'file"), "test file");
|
||||||
|
assert_eq!(query_string_cleanup("test\nfile"), "test file");
|
||||||
|
assert_eq!(query_string_cleanup("test\\file"), "test file");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_query_string_cleanup_multiple_unsupported() {
|
||||||
|
assert_eq!(query_string_cleanup("test'file\nname"), "test file name");
|
||||||
|
assert_eq!(query_string_cleanup("test\'file"), "test file");
|
||||||
|
assert_eq!(query_string_cleanup("\n'test"), " test");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_query_string_cleanup_edge_cases() {
|
||||||
|
assert_eq!(query_string_cleanup(""), "");
|
||||||
|
assert_eq!(query_string_cleanup("'"), " ");
|
||||||
|
assert_eq!(query_string_cleanup("\n"), " ");
|
||||||
|
assert_eq!(query_string_cleanup("\\"), " ");
|
||||||
|
assert_eq!(query_string_cleanup(" '\n\\ "), " ");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_query_string_cleanup_mixed_content() {
|
||||||
|
assert_eq!(
|
||||||
|
query_string_cleanup("document's content\nwith\\backslash"),
|
||||||
|
"document s content with backslash"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
query_string_cleanup("path/to'file\nextension\\test"),
|
||||||
|
"path/to file extension test"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,308 @@
|
|||||||
|
//! File search for KDE, powered by its Baloo engine.
|
||||||
|
|
||||||
|
use super::super::super::EXTENSION_ID;
|
||||||
|
use super::super::super::config::FileSearchConfig;
|
||||||
|
use super::super::super::config::SearchBy;
|
||||||
|
use super::super::should_be_filtered_out;
|
||||||
|
use crate::common::document::{DataSourceReference, Document};
|
||||||
|
use crate::extension::LOCAL_QUERY_SOURCE_TYPE;
|
||||||
|
use crate::extension::OnOpened;
|
||||||
|
use crate::util::file::sync_get_file_icon;
|
||||||
|
use camino::Utf8Path;
|
||||||
|
use configparser::ini::Ini;
|
||||||
|
use configparser::ini::WriteOptions;
|
||||||
|
use futures::stream::Stream;
|
||||||
|
use futures::stream::StreamExt;
|
||||||
|
use std::os::fd::OwnedFd;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use tokio::io::AsyncBufReadExt;
|
||||||
|
use tokio::io::BufReader;
|
||||||
|
use tokio::process::Child;
|
||||||
|
use tokio::process::Command;
|
||||||
|
use tokio_stream::wrappers::LinesStream;
|
||||||
|
|
||||||
|
/// Baloo does not support scoring, use this score for all the documents.
|
||||||
|
const SCORE: f64 = 1.0;
|
||||||
|
|
||||||
|
/// KDE6 updates the binary name to "baloosearch6", but I believe there still have
|
||||||
|
/// distros using the original name. So we need to check both.
|
||||||
|
fn cli_tool_lookup() -> Option<PathBuf> {
|
||||||
|
use which::which;
|
||||||
|
|
||||||
|
let res_path = which("baloosearch").or_else(|_| which("baloosearch6"));
|
||||||
|
res_path.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn hits(
|
||||||
|
query_string: &str,
|
||||||
|
_from: usize,
|
||||||
|
size: usize,
|
||||||
|
config: &FileSearchConfig,
|
||||||
|
) -> Result<Vec<(Document, f64)>, String> {
|
||||||
|
// Special cases that will make querying faster.
|
||||||
|
if query_string.is_empty() || size == 0 || config.search_paths.is_empty() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the tool is not found, return an empty result as well.
|
||||||
|
let Some(tool_path) = cli_tool_lookup() else {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
};
|
||||||
|
|
||||||
|
let (mut iter, _baloosearch_child_process) =
|
||||||
|
execute_baloosearch_query(tool_path, query_string, size, config)?;
|
||||||
|
|
||||||
|
// Convert results to documents
|
||||||
|
let mut hits: Vec<(Document, f64)> = Vec::new();
|
||||||
|
while let Some(res_file_path) = iter.next().await {
|
||||||
|
let file_path = res_file_path.map_err(|io_err| io_err.to_string())?;
|
||||||
|
|
||||||
|
let icon = sync_get_file_icon(&file_path);
|
||||||
|
let file_path_of_type_path = camino::Utf8Path::new(&file_path);
|
||||||
|
let r#where = file_path_of_type_path
|
||||||
|
.parent()
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
panic!(
|
||||||
|
"expect path [{}] to have a parent, but it does not",
|
||||||
|
file_path
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let file_name = file_path_of_type_path.file_name().unwrap_or_else(|| {
|
||||||
|
panic!(
|
||||||
|
"expect path [{}] to have a file name, but it does not",
|
||||||
|
file_path
|
||||||
|
);
|
||||||
|
});
|
||||||
|
let on_opened = OnOpened::Document {
|
||||||
|
url: file_path.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let doc = Document {
|
||||||
|
id: file_path.clone(),
|
||||||
|
title: Some(file_name.to_string()),
|
||||||
|
source: Some(DataSourceReference {
|
||||||
|
r#type: Some(LOCAL_QUERY_SOURCE_TYPE.into()),
|
||||||
|
name: Some(EXTENSION_ID.into()),
|
||||||
|
id: Some(EXTENSION_ID.into()),
|
||||||
|
icon: Some(String::from("font_Filesearch")),
|
||||||
|
}),
|
||||||
|
category: Some(r#where),
|
||||||
|
on_opened: Some(on_opened),
|
||||||
|
url: Some(file_path),
|
||||||
|
icon: Some(icon.to_string()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
hits.push((doc, SCORE));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(hits)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return an array containing the `baloosearch` command and its arguments.
|
||||||
|
fn build_baloosearch_query(
|
||||||
|
tool_path: PathBuf,
|
||||||
|
query_string: &str,
|
||||||
|
config: &FileSearchConfig,
|
||||||
|
) -> Vec<String> {
|
||||||
|
let tool_path = tool_path
|
||||||
|
.into_os_string()
|
||||||
|
.into_string()
|
||||||
|
.expect("binary path should be UTF-8 encoded");
|
||||||
|
|
||||||
|
let mut args = vec![tool_path];
|
||||||
|
|
||||||
|
match config.search_by {
|
||||||
|
SearchBy::Name => {
|
||||||
|
args.push(format!("filename:{query_string}"));
|
||||||
|
}
|
||||||
|
SearchBy::NameAndContents => {
|
||||||
|
args.push(query_string.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for search_path in config.search_paths.iter() {
|
||||||
|
args.extend_from_slice(&["-d".into(), search_path.clone()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
args
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawn the `baloosearch` child process and return an async iterator over its output,
|
||||||
|
/// allowing us to collect the results asynchronously.
|
||||||
|
///
|
||||||
|
/// # Return value:
|
||||||
|
///
|
||||||
|
/// * impl Stream: an async iterator that will yield the matched files
|
||||||
|
/// * Child: The handle to the baloosearch process. The child process will be
|
||||||
|
/// killed when this handle gets dropped so we need to keep it alive util we
|
||||||
|
/// exhaust the stream.
|
||||||
|
fn execute_baloosearch_query(
|
||||||
|
tool_path: PathBuf,
|
||||||
|
query_string: &str,
|
||||||
|
size: usize,
|
||||||
|
config: &FileSearchConfig,
|
||||||
|
) -> Result<(impl Stream<Item = std::io::Result<String>>, Child), String> {
|
||||||
|
let args = build_baloosearch_query(tool_path, query_string, config);
|
||||||
|
|
||||||
|
let (rx, tx) = std::io::pipe().unwrap();
|
||||||
|
let rx_owned = OwnedFd::from(rx);
|
||||||
|
let async_rx = tokio::net::unix::pipe::Receiver::from_owned_fd(rx_owned).unwrap();
|
||||||
|
let buffered_rx = BufReader::new(async_rx);
|
||||||
|
let lines = LinesStream::new(buffered_rx.lines());
|
||||||
|
|
||||||
|
let child = Command::new(&args[0])
|
||||||
|
.args(&args[1..])
|
||||||
|
.stdout(tx)
|
||||||
|
.stderr(std::process::Stdio::null())
|
||||||
|
// The child process will be killed when the Child instance gets dropped.
|
||||||
|
.kill_on_drop(true)
|
||||||
|
.spawn()
|
||||||
|
.map_err(|e| format!("Failed to spawn baloosearch: {e}"))?;
|
||||||
|
let config_clone = config.clone();
|
||||||
|
let iter = lines
|
||||||
|
.filter(move |res_path| {
|
||||||
|
std::future::ready({
|
||||||
|
match res_path {
|
||||||
|
Ok(path) => !should_be_filtered_out(&config_clone, path, false, true, true),
|
||||||
|
Err(_) => {
|
||||||
|
// Don't filter out Err() values
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.take(size);
|
||||||
|
|
||||||
|
Ok((iter, child))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn apply_config(config: &FileSearchConfig) -> Result<(), String> {
|
||||||
|
// Users can tweak Baloo via its configuration file, below are the fields that
|
||||||
|
// we need to modify:
|
||||||
|
//
|
||||||
|
// * Indexing-Enabled: turn indexing on or off
|
||||||
|
// * only basic indexing: If true, Baloo only indexes file names
|
||||||
|
// * folders: directories to index
|
||||||
|
// * exclude folders: directories to skip
|
||||||
|
//
|
||||||
|
// ```ini
|
||||||
|
// [Basic Settings]
|
||||||
|
// Indexing-Enabled=true
|
||||||
|
//
|
||||||
|
// [General]
|
||||||
|
// only basic indexing=true
|
||||||
|
// folders[$e]=$HOME/
|
||||||
|
// exclude folders[$e]=$HOME/FolderA/,$HOME/FolderB/
|
||||||
|
// ```
|
||||||
|
|
||||||
|
const SECTION_GENERAL: &str = "General";
|
||||||
|
const KEY_INCLUDE_FOLDERS: &str = "folders[$e]";
|
||||||
|
const KEY_EXCLUDE_FOLDERS: &str = "exclude folders[$e]";
|
||||||
|
const FOLDERS_SEPARATOR: &str = ",";
|
||||||
|
|
||||||
|
let rc_file_path = {
|
||||||
|
let mut home = dirs::home_dir()
|
||||||
|
.expect("cannot find the home directory, Coco should never run in such a environment");
|
||||||
|
home.push(".config/baloofilerc");
|
||||||
|
home
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse and load the rc file, it is in format INI
|
||||||
|
//
|
||||||
|
// Use `new_cs()`, the case-sensitive version of constructor as the config
|
||||||
|
// file contains uppercase letters, so it is case-sensitive.
|
||||||
|
let mut baloo_config = Ini::new_cs();
|
||||||
|
if rc_file_path.try_exists().map_err(|e| e.to_string())? {
|
||||||
|
let _ = baloo_config.load(rc_file_path.as_path())?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure indexing is enabled
|
||||||
|
let _ = baloo_config.setstr("Basic Settings", "Indexing-Enabled", Some("true"));
|
||||||
|
|
||||||
|
// Let baloo index file content if we need that
|
||||||
|
if config.search_by == SearchBy::NameAndContents {
|
||||||
|
let _ = baloo_config.setstr(SECTION_GENERAL, "only basic indexing", Some("false"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut include_folders = {
|
||||||
|
match baloo_config.get(SECTION_GENERAL, KEY_INCLUDE_FOLDERS) {
|
||||||
|
Some(str) => str
|
||||||
|
.split(FOLDERS_SEPARATOR)
|
||||||
|
.map(|str| str.to_string())
|
||||||
|
.collect::<Vec<String>>(),
|
||||||
|
None => Vec::new(),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut exclude_folders = {
|
||||||
|
match baloo_config.get(SECTION_GENERAL, KEY_EXCLUDE_FOLDERS) {
|
||||||
|
Some(str) => str
|
||||||
|
.split(FOLDERS_SEPARATOR)
|
||||||
|
.map(|str| str.to_string())
|
||||||
|
.collect::<Vec<String>>(),
|
||||||
|
None => Vec::new(),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fn ensure_path_included_include_folders(
|
||||||
|
include_folders: &mut Vec<String>,
|
||||||
|
search_path: &Utf8Path,
|
||||||
|
) {
|
||||||
|
for include_folder in include_folders.iter() {
|
||||||
|
let include_folder = Utf8Path::new(include_folder.as_str());
|
||||||
|
if search_path.starts_with(include_folder) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
include_folders.push(search_path.as_str().to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_path_and_descendants_not_excluded(
|
||||||
|
exclude_folders: &mut Vec<String>,
|
||||||
|
search_path: &Utf8Path,
|
||||||
|
) {
|
||||||
|
let mut items_to_remove = Vec::new();
|
||||||
|
for (idx, exclude_folder) in exclude_folders.iter().enumerate() {
|
||||||
|
let exclude_folder = Utf8Path::new(exclude_folder);
|
||||||
|
|
||||||
|
if exclude_folder.starts_with(search_path) {
|
||||||
|
items_to_remove.push(idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for idx in items_to_remove.into_iter().rev() {
|
||||||
|
exclude_folders.remove(idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for search_path in config.search_paths.iter() {
|
||||||
|
let search_path = Utf8Path::new(search_path.as_str());
|
||||||
|
|
||||||
|
ensure_path_included_include_folders(&mut include_folders, search_path);
|
||||||
|
ensure_path_and_descendants_not_excluded(&mut exclude_folders, search_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
let include_folders_str: String = include_folders.as_slice().join(FOLDERS_SEPARATOR);
|
||||||
|
let exclude_folders_str: String = exclude_folders.as_slice().join(FOLDERS_SEPARATOR);
|
||||||
|
|
||||||
|
let _ = baloo_config.set(
|
||||||
|
SECTION_GENERAL,
|
||||||
|
KEY_INCLUDE_FOLDERS,
|
||||||
|
Some(include_folders_str),
|
||||||
|
);
|
||||||
|
let _ = baloo_config.set(
|
||||||
|
SECTION_GENERAL,
|
||||||
|
KEY_EXCLUDE_FOLDERS,
|
||||||
|
Some(exclude_folders_str),
|
||||||
|
);
|
||||||
|
|
||||||
|
baloo_config
|
||||||
|
.pretty_write(rc_file_path.as_path(), &WriteOptions::new())
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
mod gnome;
|
||||||
|
mod kde;
|
||||||
|
|
||||||
|
use super::super::config::FileSearchConfig;
|
||||||
|
use crate::common::document::Document;
|
||||||
|
use crate::util::LinuxDesktopEnvironment;
|
||||||
|
use crate::util::get_linux_desktop_environment;
|
||||||
|
use std::ops::Deref;
|
||||||
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
|
static DESKTOP_ENVIRONMENT: LazyLock<Option<LinuxDesktopEnvironment>> =
|
||||||
|
LazyLock::new(|| get_linux_desktop_environment());
|
||||||
|
|
||||||
|
/// Dispatch to implementations powered by different backends.
|
||||||
|
pub(crate) async fn hits(
|
||||||
|
query_string: &str,
|
||||||
|
from: usize,
|
||||||
|
size: usize,
|
||||||
|
config: &FileSearchConfig,
|
||||||
|
) -> Result<Vec<(Document, f64)>, String> {
|
||||||
|
let de = DESKTOP_ENVIRONMENT.deref();
|
||||||
|
match de {
|
||||||
|
Some(LinuxDesktopEnvironment::Gnome) => gnome::hits(query_string, from, size, config).await,
|
||||||
|
Some(LinuxDesktopEnvironment::Kde) => kde::hits(query_string, from, size, config).await,
|
||||||
|
Some(LinuxDesktopEnvironment::Unsupported {
|
||||||
|
xdg_current_desktop: _,
|
||||||
|
}) => {
|
||||||
|
return Err("file search is not supported on this desktop environment".into());
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
return Err("could not determine Linux desktop environment".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn apply_config(config: &FileSearchConfig) -> Result<(), String> {
|
||||||
|
let de = DESKTOP_ENVIRONMENT.deref();
|
||||||
|
match de {
|
||||||
|
Some(LinuxDesktopEnvironment::Gnome) => gnome::apply_config(config),
|
||||||
|
Some(LinuxDesktopEnvironment::Kde) => kde::apply_config(config),
|
||||||
|
Some(LinuxDesktopEnvironment::Unsupported {
|
||||||
|
xdg_current_desktop: _,
|
||||||
|
}) => {
|
||||||
|
return Err("file search is not supported on this desktop environment".into());
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
return Err("could not determine Linux desktop environment".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
use super::super::EXTENSION_ID;
|
use super::super::EXTENSION_ID;
|
||||||
use super::super::config::FileSearchConfig;
|
use super::super::config::FileSearchConfig;
|
||||||
use super::super::config::SearchBy;
|
use super::super::config::SearchBy;
|
||||||
|
use super::should_be_filtered_out;
|
||||||
use crate::common::document::{DataSourceReference, Document};
|
use crate::common::document::{DataSourceReference, Document};
|
||||||
use crate::extension::LOCAL_QUERY_SOURCE_TYPE;
|
use crate::extension::LOCAL_QUERY_SOURCE_TYPE;
|
||||||
use crate::extension::OnOpened;
|
use crate::extension::OnOpened;
|
||||||
use crate::util::file::get_file_icon;
|
use crate::util::file::sync_get_file_icon;
|
||||||
use futures::stream::Stream;
|
use futures::stream::Stream;
|
||||||
use futures::stream::StreamExt;
|
use futures::stream::StreamExt;
|
||||||
use std::os::fd::OwnedFd;
|
use std::os::fd::OwnedFd;
|
||||||
@@ -24,7 +25,7 @@ pub(crate) async fn hits(
|
|||||||
size: usize,
|
size: usize,
|
||||||
config: &FileSearchConfig,
|
config: &FileSearchConfig,
|
||||||
) -> Result<Vec<(Document, f64)>, String> {
|
) -> Result<Vec<(Document, f64)>, String> {
|
||||||
let (mut iter, mut mdfind_child_process) =
|
let (mut iter, _mdfind_child_process) =
|
||||||
execute_mdfind_query(&query_string, from, size, &config)?;
|
execute_mdfind_query(&query_string, from, size, &config)?;
|
||||||
|
|
||||||
// Convert results to documents
|
// Convert results to documents
|
||||||
@@ -32,7 +33,7 @@ pub(crate) async fn hits(
|
|||||||
while let Some(res_file_path) = iter.next().await {
|
while let Some(res_file_path) = iter.next().await {
|
||||||
let file_path = res_file_path.map_err(|io_err| io_err.to_string())?;
|
let file_path = res_file_path.map_err(|io_err| io_err.to_string())?;
|
||||||
|
|
||||||
let icon = get_file_icon(file_path.clone()).await;
|
let icon = sync_get_file_icon(&file_path);
|
||||||
let file_path_of_type_path = camino::Utf8Path::new(&file_path);
|
let file_path_of_type_path = camino::Utf8Path::new(&file_path);
|
||||||
let r#where = file_path_of_type_path
|
let r#where = file_path_of_type_path
|
||||||
.parent()
|
.parent()
|
||||||
@@ -72,12 +73,6 @@ pub(crate) async fn hits(
|
|||||||
|
|
||||||
hits.push((doc, SCORE));
|
hits.push((doc, SCORE));
|
||||||
}
|
}
|
||||||
// Kill the mdfind process once we get the needed results to prevent zombie
|
|
||||||
// processes.
|
|
||||||
mdfind_child_process
|
|
||||||
.kill()
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("{:?}", e))?;
|
|
||||||
|
|
||||||
Ok(hits)
|
Ok(hits)
|
||||||
}
|
}
|
||||||
@@ -88,13 +83,28 @@ fn build_mdfind_query(query_string: &str, config: &FileSearchConfig) -> Vec<Stri
|
|||||||
|
|
||||||
match config.search_by {
|
match config.search_by {
|
||||||
SearchBy::Name => {
|
SearchBy::Name => {
|
||||||
args.push(format!("kMDItemFSName == '*{}*'", query_string));
|
// The tailing char 'c' makes the search case-insensitive.
|
||||||
|
//
|
||||||
|
// According to [1], we should use this syntax "kMDItemFSName ==[c] '*{}*'",
|
||||||
|
// but it does not work on my machine (macOS 26 beta 7), and you
|
||||||
|
// can find similar complaints as well [2].
|
||||||
|
//
|
||||||
|
// [1]: https://developer.apple.com/library/archive/documentation/Carbon/Conceptual/SpotlightQuery/Concepts/QueryFormat.html
|
||||||
|
// [2]: https://apple.stackexchange.com/q/263671/394687
|
||||||
|
args.push(format!("kMDItemFSName == '*{}*'c", query_string));
|
||||||
}
|
}
|
||||||
SearchBy::NameAndContents => {
|
SearchBy::NameAndContents => {
|
||||||
args.push(format!(
|
// Do not specify any File System Metadata Attribute Keys to search
|
||||||
"kMDItemFSName == '*{}*' || kMDItemTextContent == '{}'",
|
// all of them, it is case-insensitive by default.
|
||||||
query_string, query_string
|
//
|
||||||
));
|
// Previously, we use:
|
||||||
|
//
|
||||||
|
// "kMDItemFSName == '*{}*' || kMDItemTextContent == '{}'"
|
||||||
|
//
|
||||||
|
// But the kMDItemTextContent attribute does not work as expected.
|
||||||
|
// For example, if a PDF document contains both "Waterloo" and
|
||||||
|
// "waterloo", it is only matched by "Waterloo".
|
||||||
|
args.push(query_string.to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,8 +124,9 @@ fn build_mdfind_query(query_string: &str, config: &FileSearchConfig) -> Vec<Stri
|
|||||||
/// # Return value:
|
/// # Return value:
|
||||||
///
|
///
|
||||||
/// * impl Stream: an async iterator that will yield the matched files
|
/// * impl Stream: an async iterator that will yield the matched files
|
||||||
/// * Child: The handle to the mdfind process, we need to kill it once we
|
/// * Child: The handle to the mdfind process. The child process will be killed
|
||||||
/// collect all the results to avoid zombie processes.
|
/// when this handle gets dropped, we need to keep it alive until we exhaust
|
||||||
|
/// all the query results.
|
||||||
fn execute_mdfind_query(
|
fn execute_mdfind_query(
|
||||||
query_string: &str,
|
query_string: &str,
|
||||||
from: usize,
|
from: usize,
|
||||||
@@ -133,6 +144,7 @@ fn execute_mdfind_query(
|
|||||||
.args(&args[1..])
|
.args(&args[1..])
|
||||||
.stdout(tx)
|
.stdout(tx)
|
||||||
.stderr(std::process::Stdio::null())
|
.stderr(std::process::Stdio::null())
|
||||||
|
.kill_on_drop(true)
|
||||||
.spawn()
|
.spawn()
|
||||||
.map_err(|e| format!("Failed to spawn mdfind: {}", e))?;
|
.map_err(|e| format!("Failed to spawn mdfind: {}", e))?;
|
||||||
let config_clone = config.clone();
|
let config_clone = config.clone();
|
||||||
@@ -140,7 +152,7 @@ fn execute_mdfind_query(
|
|||||||
.filter(move |res_path| {
|
.filter(move |res_path| {
|
||||||
std::future::ready({
|
std::future::ready({
|
||||||
match res_path {
|
match res_path {
|
||||||
Ok(path) => !should_be_filtered_out(&config_clone, path),
|
Ok(path) => !should_be_filtered_out(&config_clone, path, false, true, true),
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
// Don't filter out Err() values
|
// Don't filter out Err() values
|
||||||
true
|
true
|
||||||
@@ -154,33 +166,25 @@ fn execute_mdfind_query(
|
|||||||
Ok((iter, child))
|
Ok((iter, child))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// If `file_path` should be removed from the search results given the filter
|
pub(crate) fn apply_config(_: &FileSearchConfig) -> Result<(), String> {
|
||||||
/// conditions specified in `config`.
|
// By default, macOS indexes all the files within a volume if indexing is
|
||||||
fn should_be_filtered_out(config: &FileSearchConfig, file_path: &str) -> bool {
|
// enabled. So, to ensure our search paths are indexed by Spotlight,
|
||||||
let is_excluded = config
|
// theoretically, we can do the following things:
|
||||||
.exclude_paths
|
//
|
||||||
.iter()
|
// 1. Ensure indexing is enabled on the volumes where our search paths reside.
|
||||||
.any(|exclude_path| file_path.starts_with(exclude_path));
|
// However, we cannot do this as doing so requires `sudo`.
|
||||||
|
//
|
||||||
if is_excluded {
|
// 2. Ensure the search paths are not excluded from indexing scope. Users can
|
||||||
return true;
|
// stop Spotlight from indexing a directory by:
|
||||||
}
|
// 1. adding it to the "Privacy" list in 'System Settings'. Coco cannot
|
||||||
|
// modify this list, since the only way to change it is manually
|
||||||
let matches_file_type = if config.file_types.is_empty() {
|
// through System Settings.
|
||||||
true
|
// 2. Renaming directory name, adding a `.noindex` file extension to it.
|
||||||
} else {
|
// I don't want to use this trick, users won't feel comfortable and it
|
||||||
let path_obj = camino::Utf8Path::new(&file_path);
|
// could break at any time.
|
||||||
if let Some(extension) = path_obj.extension() {
|
// 3. Creating a `.metadata_never_index` file within the directory (no longer works
|
||||||
config
|
// since macOS Mojave)
|
||||||
.file_types
|
//
|
||||||
.iter()
|
// There is nothing we can do.
|
||||||
.any(|file_type| file_type == extension)
|
Ok(())
|
||||||
} else {
|
|
||||||
// `config.file_types` is not empty, then the search results
|
|
||||||
// should have extensions.
|
|
||||||
false
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
!matches_file_type
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,396 @@
|
|||||||
#[cfg(target_os = "macos")]
|
use cfg_if::cfg_if;
|
||||||
mod macos;
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
mod windows;
|
|
||||||
|
|
||||||
// `hits()` function is platform-specific, export the corresponding impl.
|
// * hits: the implementation of search
|
||||||
#[cfg(target_os = "macos")]
|
//
|
||||||
pub(crate) use macos::hits;
|
// * apply_config: Routines that should be performed to keep "other things"
|
||||||
#[cfg(target_os = "windows")]
|
// synchronous with the passed configuration.
|
||||||
pub(crate) use windows::hits;
|
// Currently, "other things" only include system indexer's setting entries.
|
||||||
|
cfg_if! {
|
||||||
|
if #[cfg(target_os = "linux")] {
|
||||||
|
mod linux;
|
||||||
|
pub(crate) use linux::hits;
|
||||||
|
pub(crate) use linux::apply_config;
|
||||||
|
} else if #[cfg(target_os = "macos")] {
|
||||||
|
mod macos;
|
||||||
|
pub(crate) use macos::hits;
|
||||||
|
pub(crate) use macos::apply_config;
|
||||||
|
} else if #[cfg(target_os = "windows")] {
|
||||||
|
mod windows;
|
||||||
|
pub(crate) use windows::hits;
|
||||||
|
pub(crate) use windows::apply_config;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg_if! {
|
||||||
|
if #[cfg(not(target_os = "windows"))] {
|
||||||
|
use super::config::FileSearchConfig;
|
||||||
|
use camino::Utf8Path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// If `file_path` should be removed from the search results given the filter
|
||||||
|
/// conditions specified in `config`.
|
||||||
|
#[cfg(not(target_os = "windows"))] // Not used on Windows
|
||||||
|
pub(crate) fn should_be_filtered_out(
|
||||||
|
config: &FileSearchConfig,
|
||||||
|
file_path: &str,
|
||||||
|
check_search_paths: bool,
|
||||||
|
check_exclude_paths: bool,
|
||||||
|
check_file_type: bool,
|
||||||
|
) -> bool {
|
||||||
|
let file_path = Utf8Path::new(file_path);
|
||||||
|
|
||||||
|
if check_search_paths {
|
||||||
|
// search path
|
||||||
|
let in_search_paths = config.search_paths.iter().any(|search_path| {
|
||||||
|
let search_path = Utf8Path::new(search_path);
|
||||||
|
file_path.starts_with(search_path)
|
||||||
|
});
|
||||||
|
if !in_search_paths {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if check_exclude_paths {
|
||||||
|
// exclude path
|
||||||
|
let is_excluded = config
|
||||||
|
.exclude_paths
|
||||||
|
.iter()
|
||||||
|
.any(|exclude_path| file_path.starts_with(exclude_path));
|
||||||
|
if is_excluded {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if check_file_type {
|
||||||
|
// file type
|
||||||
|
let matches_file_type = if config.file_types.is_empty() {
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
let path_obj = camino::Utf8Path::new(&file_path);
|
||||||
|
if let Some(extension) = path_obj.extension() {
|
||||||
|
config
|
||||||
|
.file_types
|
||||||
|
.iter()
|
||||||
|
.any(|file_type| file_type == extension)
|
||||||
|
} else {
|
||||||
|
// `config.file_types` is not empty, the hit files should have extensions.
|
||||||
|
false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if !matches_file_type {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
// should_be_filtered_out() is not defined for Windows
|
||||||
|
#[cfg(all(test, not(target_os = "windows")))]
|
||||||
|
mod tests {
|
||||||
|
use super::super::config::SearchBy;
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_should_be_filtered_out_with_no_check() {
|
||||||
|
let config = FileSearchConfig {
|
||||||
|
search_paths: vec!["/home/user/Documents".to_string()],
|
||||||
|
exclude_paths: vec![],
|
||||||
|
file_types: vec!["fffffff".into()],
|
||||||
|
search_by: SearchBy::Name,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(!should_be_filtered_out(
|
||||||
|
&config, "abbc", false, false, false
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_should_be_filtered_out_search_paths() {
|
||||||
|
let config = FileSearchConfig {
|
||||||
|
search_paths: vec![
|
||||||
|
"/home/user/Documents".to_string(),
|
||||||
|
"/home/user/Downloads".to_string(),
|
||||||
|
],
|
||||||
|
exclude_paths: vec![],
|
||||||
|
file_types: vec![],
|
||||||
|
search_by: SearchBy::Name,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Files in search paths should not be filtered
|
||||||
|
assert!(!should_be_filtered_out(
|
||||||
|
&config,
|
||||||
|
"/home/user/Documents/file.txt",
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
));
|
||||||
|
assert!(!should_be_filtered_out(
|
||||||
|
&config,
|
||||||
|
"/home/user/Downloads/image.jpg",
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
));
|
||||||
|
assert!(!should_be_filtered_out(
|
||||||
|
&config,
|
||||||
|
"/home/user/Documents/folder/file.txt",
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
));
|
||||||
|
|
||||||
|
// Files not in search paths should be filtered
|
||||||
|
assert!(should_be_filtered_out(
|
||||||
|
&config,
|
||||||
|
"/home/user/Pictures/photo.jpg",
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
));
|
||||||
|
assert!(should_be_filtered_out(
|
||||||
|
&config,
|
||||||
|
"/tmp/tempfile",
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
));
|
||||||
|
assert!(should_be_filtered_out(
|
||||||
|
&config,
|
||||||
|
"/usr/bin/ls",
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_should_be_filtered_out_exclude_paths() {
|
||||||
|
let config = FileSearchConfig {
|
||||||
|
search_paths: vec!["/home/user".to_string()],
|
||||||
|
exclude_paths: vec![
|
||||||
|
"/home/user/Trash".to_string(),
|
||||||
|
"/home/user/.cache".to_string(),
|
||||||
|
],
|
||||||
|
file_types: vec![],
|
||||||
|
search_by: SearchBy::Name,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Files in search paths but not excluded should not be filtered
|
||||||
|
assert!(!should_be_filtered_out(
|
||||||
|
&config,
|
||||||
|
"/home/user/Documents/file.txt",
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
));
|
||||||
|
assert!(!should_be_filtered_out(
|
||||||
|
&config,
|
||||||
|
"/home/user/Downloads/image.jpg",
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
));
|
||||||
|
|
||||||
|
// Files in excluded paths should be filtered
|
||||||
|
assert!(should_be_filtered_out(
|
||||||
|
&config,
|
||||||
|
"/home/user/Trash/deleted_file",
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
));
|
||||||
|
assert!(should_be_filtered_out(
|
||||||
|
&config,
|
||||||
|
"/home/user/.cache/temp",
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
));
|
||||||
|
assert!(should_be_filtered_out(
|
||||||
|
&config,
|
||||||
|
"/home/user/Trash/folder/file.txt",
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_should_be_filtered_out_file_types() {
|
||||||
|
let config = FileSearchConfig {
|
||||||
|
search_paths: vec!["/home/user/Documents".to_string()],
|
||||||
|
exclude_paths: vec![],
|
||||||
|
file_types: vec!["txt".to_string(), "md".to_string()],
|
||||||
|
search_by: SearchBy::Name,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Files with allowed extensions should not be filtered
|
||||||
|
assert!(!should_be_filtered_out(
|
||||||
|
&config,
|
||||||
|
"/home/user/Documents/notes.txt",
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
));
|
||||||
|
assert!(!should_be_filtered_out(
|
||||||
|
&config,
|
||||||
|
"/home/user/Documents/readme.md",
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
));
|
||||||
|
|
||||||
|
// Files with disallowed extensions should be filtered
|
||||||
|
assert!(should_be_filtered_out(
|
||||||
|
&config,
|
||||||
|
"/home/user/Documents/image.jpg",
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
));
|
||||||
|
assert!(should_be_filtered_out(
|
||||||
|
&config,
|
||||||
|
"/home/user/Documents/document.pdf",
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
));
|
||||||
|
|
||||||
|
// Files without extensions should be filtered when file_types is not empty
|
||||||
|
assert!(should_be_filtered_out(
|
||||||
|
&config,
|
||||||
|
"/home/user/Documents/file",
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
));
|
||||||
|
assert!(should_be_filtered_out(
|
||||||
|
&config,
|
||||||
|
"/home/user/Documents/folder",
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_should_be_filtered_out_empty_file_types() {
|
||||||
|
let config = FileSearchConfig {
|
||||||
|
search_paths: vec!["/home/user/Documents".to_string()],
|
||||||
|
exclude_paths: vec![],
|
||||||
|
file_types: vec![],
|
||||||
|
search_by: SearchBy::Name,
|
||||||
|
};
|
||||||
|
|
||||||
|
// When file_types is empty, all file types should be allowed
|
||||||
|
assert!(!should_be_filtered_out(
|
||||||
|
&config,
|
||||||
|
"/home/user/Documents/file.txt",
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
));
|
||||||
|
assert!(!should_be_filtered_out(
|
||||||
|
&config,
|
||||||
|
"/home/user/Documents/image.jpg",
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
));
|
||||||
|
assert!(!should_be_filtered_out(
|
||||||
|
&config,
|
||||||
|
"/home/user/Documents/document",
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
));
|
||||||
|
assert!(!should_be_filtered_out(
|
||||||
|
&config,
|
||||||
|
"/home/user/Documents/folder/",
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_should_be_filtered_out_combined_filters() {
|
||||||
|
let config = FileSearchConfig {
|
||||||
|
search_paths: vec!["/home/user".to_string()],
|
||||||
|
exclude_paths: vec!["/home/user/Trash".to_string()],
|
||||||
|
file_types: vec!["txt".to_string()],
|
||||||
|
search_by: SearchBy::Name,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Should pass all filters: in search path, not excluded, and correct file type
|
||||||
|
assert!(!should_be_filtered_out(
|
||||||
|
&config,
|
||||||
|
"/home/user/Documents/notes.txt",
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
));
|
||||||
|
|
||||||
|
// Fails file type filter
|
||||||
|
assert!(should_be_filtered_out(
|
||||||
|
&config,
|
||||||
|
"/home/user/Documents/image.jpg",
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
));
|
||||||
|
|
||||||
|
// Fails exclude path filter
|
||||||
|
assert!(should_be_filtered_out(
|
||||||
|
&config,
|
||||||
|
"/home/user/Trash/deleted.txt",
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
));
|
||||||
|
|
||||||
|
// Fails search path filter
|
||||||
|
assert!(should_be_filtered_out(
|
||||||
|
&config,
|
||||||
|
"/tmp/temp.txt",
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_should_be_filtered_out_edge_cases() {
|
||||||
|
let config = FileSearchConfig {
|
||||||
|
search_paths: vec!["/home/user".to_string()],
|
||||||
|
exclude_paths: vec![],
|
||||||
|
file_types: vec!["txt".to_string()],
|
||||||
|
search_by: SearchBy::Name,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Empty path
|
||||||
|
assert!(should_be_filtered_out(&config, "", true, true, true));
|
||||||
|
|
||||||
|
// Root path
|
||||||
|
assert!(should_be_filtered_out(&config, "/", true, true, true));
|
||||||
|
|
||||||
|
// Path that starts with search path but continues differently
|
||||||
|
assert!(!should_be_filtered_out(
|
||||||
|
&config,
|
||||||
|
"/home/user/document.txt",
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
));
|
||||||
|
assert!(should_be_filtered_out(
|
||||||
|
&config,
|
||||||
|
"/home/user_other/file.txt",
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,234 @@
|
|||||||
|
//! Wraps Windows `ISearchCrawlScopeManager`
|
||||||
|
|
||||||
|
mod searchapi_h_bindings;
|
||||||
|
|
||||||
|
use searchapi_h_bindings::CLSID_CSEARCH_MANAGER;
|
||||||
|
use searchapi_h_bindings::IID_ISEARCH_MANAGER;
|
||||||
|
use searchapi_h_bindings::{
|
||||||
|
HRESULT, ISearchCatalogManager, ISearchCatalogManagerVtbl, ISearchCrawlScopeManager,
|
||||||
|
ISearchCrawlScopeManagerVtbl, ISearchManager,
|
||||||
|
};
|
||||||
|
use std::ffi::OsStr;
|
||||||
|
use std::ffi::OsString;
|
||||||
|
use std::os::windows::ffi::OsStrExt;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::ptr::null_mut;
|
||||||
|
use windows::core::w;
|
||||||
|
use windows_sys::Win32::Foundation::S_OK;
|
||||||
|
use windows_sys::Win32::System::Com::{
|
||||||
|
CLSCTX_LOCAL_SERVER, COINIT_APARTMENTTHREADED, CoCreateInstance, CoInitializeEx, CoUninitialize,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
#[error("{msg}, function [{function}], HRESULT [{hresult}]")]
|
||||||
|
pub(crate) struct WindowSearchApiError {
|
||||||
|
function: &'static str,
|
||||||
|
hresult: HRESULT,
|
||||||
|
msg: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// See doc of [`Rule`].
|
||||||
|
#[derive(Debug, PartialEq)]
|
||||||
|
pub(crate) enum RuleMode {
|
||||||
|
Inclusion,
|
||||||
|
Exclusion,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A rule adds or removes one or more paths to/from the Windows Search index.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub(crate) struct Rule {
|
||||||
|
/// A path or path pattern (wildcard supported, only for exclusion rule) that
|
||||||
|
/// specifies the paths that this rule applies to.
|
||||||
|
///
|
||||||
|
/// The rules used by Windows Search actually specify URLs rather than paths,
|
||||||
|
/// but we only care about paths, i.e., URLs with schema `file://`
|
||||||
|
pub(crate) paths: PathBuf,
|
||||||
|
/// Add or remove paths to/from the index.
|
||||||
|
pub(crate) mode: RuleMode,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A wrapper around Window's `ISearchCrawlScopeManager` type
|
||||||
|
pub(crate) struct CrawlScopeManager {
|
||||||
|
i_search_crawl_scope_manager: *mut ISearchCrawlScopeManager,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CrawlScopeManager {
|
||||||
|
fn vtable(&self) -> *mut ISearchCrawlScopeManagerVtbl {
|
||||||
|
unsafe { (*self.i_search_crawl_scope_manager).lpVtbl }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn new() -> Result<Self, WindowSearchApiError> {
|
||||||
|
unsafe {
|
||||||
|
// 1. Initialize the COM library, use Apartment-threading as Self is not Send/Sync
|
||||||
|
let hr = CoInitializeEx(null_mut(), COINIT_APARTMENTTHREADED as u32);
|
||||||
|
if hr != S_OK {
|
||||||
|
return Err(WindowSearchApiError {
|
||||||
|
function: "CoInitializeEx()",
|
||||||
|
hresult: hr,
|
||||||
|
msg: "failed to initialize the COM library".into(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Create an instance of the CSearchManager.
|
||||||
|
let mut search_manager: *mut ISearchManager = null_mut();
|
||||||
|
let hr = CoCreateInstance(
|
||||||
|
&CLSID_CSEARCH_MANAGER, // CLSID of the object
|
||||||
|
null_mut(), // No outer unknown
|
||||||
|
CLSCTX_LOCAL_SERVER, // Server context
|
||||||
|
&IID_ISEARCH_MANAGER, // IID of the interface we want
|
||||||
|
&mut search_manager as *mut _ as *mut _, // Pointer to receive the interface
|
||||||
|
);
|
||||||
|
if hr != S_OK {
|
||||||
|
return Err(WindowSearchApiError {
|
||||||
|
function: "CoCreateInstance()",
|
||||||
|
hresult: hr,
|
||||||
|
msg: "failed to initialize ISearchManager".into(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
assert!(!search_manager.is_null());
|
||||||
|
|
||||||
|
let search_manger_vtable = (*search_manager).lpVtbl;
|
||||||
|
let search_manager_fn_get_catalog = (*search_manger_vtable).GetCatalog.unwrap();
|
||||||
|
let mut search_catalog_manager: *mut ISearchCatalogManager = null_mut();
|
||||||
|
let string_literal_system_index = w!("SystemIndex");
|
||||||
|
let hr: HRESULT = search_manager_fn_get_catalog(
|
||||||
|
search_manager,
|
||||||
|
string_literal_system_index.0,
|
||||||
|
&mut search_catalog_manager as *mut *mut ISearchCatalogManager,
|
||||||
|
);
|
||||||
|
if hr != S_OK {
|
||||||
|
return Err(WindowSearchApiError {
|
||||||
|
function: "ISearchManager::GetCatalog()",
|
||||||
|
hresult: hr,
|
||||||
|
msg: "failed to initialize ISearchCatalogManager".into(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
assert!(!search_catalog_manager.is_null());
|
||||||
|
|
||||||
|
let search_catalog_manager_vtable: *mut ISearchCatalogManagerVtbl =
|
||||||
|
(*search_catalog_manager).lpVtbl;
|
||||||
|
let fn_get_crawl_scope_manager = (*search_catalog_manager_vtable)
|
||||||
|
.GetCrawlScopeManager
|
||||||
|
.unwrap();
|
||||||
|
let mut search_crawl_scope_manager: *mut ISearchCrawlScopeManager = null_mut();
|
||||||
|
let hr =
|
||||||
|
fn_get_crawl_scope_manager(search_catalog_manager, &mut search_crawl_scope_manager);
|
||||||
|
if hr != S_OK {
|
||||||
|
return Err(WindowSearchApiError {
|
||||||
|
function: "ISearchCatalogManager::GetCrawlScopeManager()",
|
||||||
|
hresult: hr,
|
||||||
|
msg: "failed to initialize ISearchCrawlScopeManager".into(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
assert!(!search_crawl_scope_manager.is_null());
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
i_search_crawl_scope_manager: search_crawl_scope_manager,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Does nothing unless you [`commit()`] the changes.
|
||||||
|
pub(crate) fn add_rule(&mut self, rule: Rule) -> Result<(), WindowSearchApiError> {
|
||||||
|
unsafe {
|
||||||
|
let vtable = self.vtable();
|
||||||
|
|
||||||
|
let fn_add_rule = (*vtable).AddUserScopeRule.unwrap();
|
||||||
|
|
||||||
|
let url: Vec<u16> = encode_path(&rule.paths);
|
||||||
|
let inclusion = (rule.mode == RuleMode::Inclusion) as i32;
|
||||||
|
let override_child_rules = true as i32;
|
||||||
|
let follow_flag = 0x1_u32; /* FF_INDEXCOMPLEXURLS */
|
||||||
|
|
||||||
|
let hr = fn_add_rule(
|
||||||
|
self.i_search_crawl_scope_manager,
|
||||||
|
url.as_ptr(),
|
||||||
|
inclusion,
|
||||||
|
override_child_rules,
|
||||||
|
follow_flag,
|
||||||
|
);
|
||||||
|
|
||||||
|
if hr != S_OK {
|
||||||
|
return Err(WindowSearchApiError {
|
||||||
|
function: "ISearchCrawlScopeManager::AddUserScopeRule()",
|
||||||
|
hresult: hr,
|
||||||
|
msg: "failed to add scope rule".into(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn is_path_included<P: AsRef<Path> + ?Sized>(
|
||||||
|
&self,
|
||||||
|
path: &P,
|
||||||
|
) -> Result<bool, WindowSearchApiError> {
|
||||||
|
unsafe {
|
||||||
|
let vtable = self.vtable();
|
||||||
|
let fn_included_in_crawl_scope = (*vtable).IncludedInCrawlScope.unwrap();
|
||||||
|
let path: Vec<u16> = encode_path(path);
|
||||||
|
|
||||||
|
let mut included: i32 = 0 /* false */;
|
||||||
|
|
||||||
|
let hr = fn_included_in_crawl_scope(
|
||||||
|
self.i_search_crawl_scope_manager,
|
||||||
|
path.as_ptr(),
|
||||||
|
&mut included,
|
||||||
|
);
|
||||||
|
if hr != S_OK {
|
||||||
|
return Err(WindowSearchApiError {
|
||||||
|
function: "ISearchCrawlScopeManager::IncludedInCrawlScope()",
|
||||||
|
hresult: hr,
|
||||||
|
msg: "failed to call IncludedInCrawlScope()".into(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(included == 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn commit(&self) -> Result<(), WindowSearchApiError> {
|
||||||
|
unsafe {
|
||||||
|
let vtable = self.vtable();
|
||||||
|
let fn_commit = (*vtable).SaveAll.unwrap();
|
||||||
|
let hr = fn_commit(self.i_search_crawl_scope_manager);
|
||||||
|
if hr != S_OK {
|
||||||
|
return Err(WindowSearchApiError {
|
||||||
|
function: "ISearchCrawlScopeManager::SaveAll()",
|
||||||
|
hresult: hr,
|
||||||
|
msg: "failed to commit the changes".into(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for CrawlScopeManager {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
unsafe {
|
||||||
|
CoUninitialize();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode_path<P: AsRef<Path> + ?Sized>(path: &P) -> Vec<u16> {
|
||||||
|
let mut buffer = OsString::new();
|
||||||
|
|
||||||
|
// schema
|
||||||
|
buffer.push("file:///");
|
||||||
|
buffer.push(path.as_ref().as_os_str());
|
||||||
|
|
||||||
|
osstr_to_wstr(&buffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn osstr_to_wstr<S: AsRef<OsStr> + ?Sized>(str: &S) -> Vec<u16> {
|
||||||
|
let os_str: &OsStr = str.as_ref();
|
||||||
|
let mut chars = os_str.encode_wide().collect::<Vec<u16>>();
|
||||||
|
chars.push(0 /* NUL */);
|
||||||
|
|
||||||
|
chars
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
//! Rust binding of the types and functions declared in 'searchapi.h'
|
||||||
|
|
||||||
|
#![allow(unused)]
|
||||||
|
#![allow(non_camel_case_types)]
|
||||||
|
#![allow(non_snake_case)]
|
||||||
|
#![allow(non_upper_case_globals)]
|
||||||
|
#![allow(unsafe_op_in_unsafe_fn)]
|
||||||
|
#![allow(unnecessary_transmutes)]
|
||||||
|
|
||||||
|
include!(concat!(env!("OUT_DIR"), "/searchapi_bindings.rs"));
|
||||||
|
|
||||||
|
// The bindings.rs contains a GUID type as well, we use the one provided by
|
||||||
|
// the windows_sys crate here.
|
||||||
|
use windows_sys::core::GUID as WIN_SYS_GUID;
|
||||||
|
|
||||||
|
// https://github.com/search?q=CLSID_CSearchManager+language%3AC&type=code&l=C
|
||||||
|
pub(crate) static CLSID_CSEARCH_MANAGER: WIN_SYS_GUID = WIN_SYS_GUID {
|
||||||
|
data1: 0x7d096c5f,
|
||||||
|
data2: 0xac08,
|
||||||
|
data3: 0x4f1f,
|
||||||
|
data4: [0xbe, 0xb7, 0x5c, 0x22, 0xc5, 0x17, 0xce, 0x39],
|
||||||
|
};
|
||||||
|
|
||||||
|
// https://github.com/search?q=IID_ISearchManager+language%3AC&type=code
|
||||||
|
pub(crate) static IID_ISEARCH_MANAGER: WIN_SYS_GUID = WIN_SYS_GUID {
|
||||||
|
data1: 0xAB310581,
|
||||||
|
data2: 0xac80,
|
||||||
|
data3: 0x11d1,
|
||||||
|
data4: [0x8d, 0xf3, 0x00, 0xc0, 0x4f, 0xb6, 0xef, 0x69],
|
||||||
|
};
|
||||||
@@ -3,13 +3,17 @@
|
|||||||
//! https://github.com/IRONAGE-Park/rag-sample/blob/3f0ad8c8012026cd3a7e453d08f041609426cb91/src/native/windows.rs
|
//! https://github.com/IRONAGE-Park/rag-sample/blob/3f0ad8c8012026cd3a7e453d08f041609426cb91/src/native/windows.rs
|
||||||
//! is the starting point of this implementation.
|
//! is the starting point of this implementation.
|
||||||
|
|
||||||
|
mod crawl_scope_manager;
|
||||||
|
|
||||||
use super::super::EXTENSION_ID;
|
use super::super::EXTENSION_ID;
|
||||||
use super::super::config::FileSearchConfig;
|
use super::super::config::FileSearchConfig;
|
||||||
use super::super::config::SearchBy;
|
use super::super::config::SearchBy;
|
||||||
use crate::common::document::{DataSourceReference, Document};
|
use crate::common::document::{DataSourceReference, Document};
|
||||||
use crate::extension::LOCAL_QUERY_SOURCE_TYPE;
|
use crate::extension::LOCAL_QUERY_SOURCE_TYPE;
|
||||||
use crate::extension::OnOpened;
|
use crate::extension::OnOpened;
|
||||||
use crate::util::file::get_file_icon;
|
use crate::util::file::sync_get_file_icon;
|
||||||
|
use std::borrow::Borrow;
|
||||||
|
use std::path::PathBuf;
|
||||||
use windows::{
|
use windows::{
|
||||||
Win32::System::{
|
Win32::System::{
|
||||||
Com::{CLSCTX_INPROC_SERVER, CoCreateInstance},
|
Com::{CLSCTX_INPROC_SERVER, CoCreateInstance},
|
||||||
@@ -420,7 +424,7 @@ pub(crate) async fn hits(
|
|||||||
// "file:C:/Users/desktop.ini" => "C:/Users/desktop.ini"
|
// "file:C:/Users/desktop.ini" => "C:/Users/desktop.ini"
|
||||||
let file_path = &item_url[ITEM_URL_PREFIX_LEN..];
|
let file_path = &item_url[ITEM_URL_PREFIX_LEN..];
|
||||||
|
|
||||||
let icon = get_file_icon(file_path.to_string()).await;
|
let icon = sync_get_file_icon(file_path);
|
||||||
let file_path_of_type_path = camino::Utf8Path::new(&file_path);
|
let file_path_of_type_path = camino::Utf8Path::new(&file_path);
|
||||||
let r#where = file_path_of_type_path
|
let r#where = file_path_of_type_path
|
||||||
.parent()
|
.parent()
|
||||||
@@ -468,6 +472,85 @@ pub(crate) async fn hits(
|
|||||||
Ok(hits)
|
Ok(hits)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn apply_config(config: &FileSearchConfig) -> Result<(), String> {
|
||||||
|
// To ensure Windows Search indexer index the paths we specified in the
|
||||||
|
// config, we will:
|
||||||
|
//
|
||||||
|
// 1. Add an inclusion rule for every search path to ensure indexer index
|
||||||
|
// them
|
||||||
|
// 2. For the exclude paths, we exclude them from the crawl scope if they
|
||||||
|
// were not included in the scope before we update the scope. Otherwise,
|
||||||
|
// we cannot exclude them as doing that could potentially break other
|
||||||
|
// apps (by removing the indexes they rely on).
|
||||||
|
//
|
||||||
|
// Windows APIs are pretty smart. They won't blindly add an inclusion rule if
|
||||||
|
// the path you are trying to include is already included. The same applies
|
||||||
|
// to exclusion rules as well. Since Windows APIs handle these checks for us,
|
||||||
|
// we don't need to worry about them.
|
||||||
|
|
||||||
|
use crawl_scope_manager::CrawlScopeManager;
|
||||||
|
use crawl_scope_manager::Rule;
|
||||||
|
use crawl_scope_manager::RuleMode;
|
||||||
|
use std::borrow::Cow;
|
||||||
|
|
||||||
|
/// Windows APIs need the path to contain a tailing '\'
|
||||||
|
fn add_tailing_backslash(path: &str) -> Cow<'_, str> {
|
||||||
|
if path.ends_with(r#"\"#) {
|
||||||
|
Cow::Borrowed(path)
|
||||||
|
} else {
|
||||||
|
let mut owned = path.to_string();
|
||||||
|
owned.push_str(r#"\"#);
|
||||||
|
|
||||||
|
Cow::Owned(owned)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut manager = CrawlScopeManager::new().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let search_paths = &config.search_paths;
|
||||||
|
let exclude_paths = &config.exclude_paths;
|
||||||
|
|
||||||
|
// indexes to `exclude_paths` of the paths we need to exclude
|
||||||
|
let mut paths_to_exclude: Vec<usize> = Vec::new();
|
||||||
|
for (idx, exclude_path) in exclude_paths.into_iter().enumerate() {
|
||||||
|
let exclude_path = add_tailing_backslash(&exclude_path);
|
||||||
|
let exclude_path: &str = exclude_path.borrow();
|
||||||
|
|
||||||
|
if !manager
|
||||||
|
.is_path_included(exclude_path)
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
{
|
||||||
|
paths_to_exclude.push(idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for search_path in search_paths {
|
||||||
|
let inclusion_rule = Rule {
|
||||||
|
paths: PathBuf::from(add_tailing_backslash(&search_path).into_owned()),
|
||||||
|
mode: RuleMode::Inclusion,
|
||||||
|
};
|
||||||
|
|
||||||
|
manager
|
||||||
|
.add_rule(inclusion_rule)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
|
||||||
|
for idx in paths_to_exclude {
|
||||||
|
let exclusion_rule = Rule {
|
||||||
|
paths: PathBuf::from(add_tailing_backslash(&exclude_paths[idx]).into_owned()),
|
||||||
|
mode: RuleMode::Exclusion,
|
||||||
|
};
|
||||||
|
|
||||||
|
manager
|
||||||
|
.add_rule(exclusion_rule)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
|
||||||
|
manager.commit().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
// Skip these tests in our CI, they fail with the following error
|
// Skip these tests in our CI, they fail with the following error
|
||||||
// "SQL is invalid: "0x80041820""
|
// "SQL is invalid: "0x80041820""
|
||||||
//
|
//
|
||||||
@@ -19,7 +19,7 @@ pub(crate) const PLUGIN_JSON_FILE: &str = r#"
|
|||||||
{
|
{
|
||||||
"id": "File Search",
|
"id": "File Search",
|
||||||
"name": "File Search",
|
"name": "File Search",
|
||||||
"platforms": ["macos", "windows"],
|
"platforms": ["macos", "windows", "linux"],
|
||||||
"description": "Search files on your system",
|
"description": "Search files on your system",
|
||||||
"icon": "font_Filesearch",
|
"icon": "font_Filesearch",
|
||||||
"type": "extension"
|
"type": "extension"
|
||||||
|
|||||||
@@ -3,10 +3,11 @@
|
|||||||
pub mod ai_overview;
|
pub mod ai_overview;
|
||||||
pub mod application;
|
pub mod application;
|
||||||
pub mod calculator;
|
pub mod calculator;
|
||||||
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
|
||||||
pub mod file_search;
|
pub mod file_search;
|
||||||
pub mod pizza_engine_runtime;
|
pub mod pizza_engine_runtime;
|
||||||
pub mod quick_ai_access;
|
pub mod quick_ai_access;
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
pub mod window_management;
|
||||||
|
|
||||||
use super::Extension;
|
use super::Extension;
|
||||||
use crate::SearchSourceRegistry;
|
use crate::SearchSourceRegistry;
|
||||||
@@ -15,6 +16,8 @@ use crate::extension::{
|
|||||||
ExtensionBundleIdBorrowed, PLUGIN_JSON_FILE_NAME, alter_extension_json_file,
|
ExtensionBundleIdBorrowed, PLUGIN_JSON_FILE_NAME, alter_extension_json_file,
|
||||||
};
|
};
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
|
use file_search::config::FileSearchConfig;
|
||||||
|
use file_search::implementation::apply_config as file_search_apply_config;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use tauri::{AppHandle, Manager};
|
use tauri::{AppHandle, Manager};
|
||||||
|
|
||||||
@@ -173,17 +176,26 @@ pub(crate) async fn list_built_in_extensions(
|
|||||||
.await?,
|
.await?,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
built_in_extensions.push(
|
||||||
|
load_built_in_extension(
|
||||||
|
&dir,
|
||||||
|
file_search::EXTENSION_ID,
|
||||||
|
file_search::PLUGIN_JSON_FILE,
|
||||||
|
)
|
||||||
|
.await?,
|
||||||
|
);
|
||||||
|
|
||||||
cfg_if::cfg_if! {
|
cfg_if::cfg_if! {
|
||||||
if #[cfg(any(target_os = "macos", target_os = "windows"))] {
|
if #[cfg(target_os = "macos")] {
|
||||||
built_in_extensions.push(
|
built_in_extensions.push(
|
||||||
load_built_in_extension(
|
load_built_in_extension(
|
||||||
&dir,
|
&dir,
|
||||||
file_search::EXTENSION_ID,
|
window_management::EXTENSION_ID,
|
||||||
file_search::PLUGIN_JSON_FILE,
|
window_management::PLUGIN_JSON_FILE,
|
||||||
)
|
)
|
||||||
.await?,
|
.await?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(built_in_extensions)
|
Ok(built_in_extensions)
|
||||||
@@ -212,16 +224,28 @@ pub(super) async fn init_built_in_extension(
|
|||||||
log::debug!("built-in extension [{}] initialized", extension.id);
|
log::debug!("built-in extension [{}] initialized", extension.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if extension.id == file_search::EXTENSION_ID {
|
||||||
|
let file_system_search = file_search::FileSearchExtensionSearchSource;
|
||||||
|
search_source_registry
|
||||||
|
.register_source(file_system_search)
|
||||||
|
.await;
|
||||||
|
let file_search_config = FileSearchConfig::get(tauri_app_handle);
|
||||||
|
file_search_apply_config(&file_search_config)?;
|
||||||
|
log::debug!("built-in extension [{}] initialized", extension.id);
|
||||||
|
}
|
||||||
|
|
||||||
cfg_if::cfg_if! {
|
cfg_if::cfg_if! {
|
||||||
if #[cfg(any(target_os = "macos", target_os = "windows"))] {
|
if #[cfg(target_os = "macos")] {
|
||||||
if extension.id == file_search::EXTENSION_ID {
|
if extension.id == window_management::EXTENSION_ID {
|
||||||
let file_system_search = file_search::FileSearchExtensionSearchSource;
|
let file_system_search = window_management::search_source::WindowManagementSearchSource;
|
||||||
search_source_registry
|
search_source_registry
|
||||||
.register_source(file_system_search)
|
.register_source(file_system_search)
|
||||||
.await;
|
.await;
|
||||||
log::debug!("built-in extension [{}] initialized", extension.id);
|
|
||||||
}
|
window_management::set_up_commands_hotkeys(tauri_app_handle, extension)?;
|
||||||
}
|
log::debug!("built-in extension [{}] initialized", extension.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -299,19 +323,51 @@ pub(crate) async fn enable_built_in_extension(
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if bundle_id.extension_id == file_search::EXTENSION_ID {
|
||||||
|
let file_system_search = file_search::FileSearchExtensionSearchSource;
|
||||||
|
search_source_registry_tauri_state
|
||||||
|
.register_source(file_system_search)
|
||||||
|
.await;
|
||||||
|
alter_extension_json_file(
|
||||||
|
&get_built_in_extension_directory(tauri_app_handle),
|
||||||
|
bundle_id,
|
||||||
|
update_extension,
|
||||||
|
)?;
|
||||||
|
let file_search_config = FileSearchConfig::get(tauri_app_handle);
|
||||||
|
file_search_apply_config(&file_search_config)?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
cfg_if::cfg_if! {
|
cfg_if::cfg_if! {
|
||||||
if #[cfg(any(target_os = "macos", target_os = "windows"))] {
|
if #[cfg(target_os = "macos")] {
|
||||||
if bundle_id.extension_id == file_search::EXTENSION_ID {
|
if bundle_id.extension_id == window_management::EXTENSION_ID
|
||||||
let file_system_search = file_search::FileSearchExtensionSearchSource;
|
&& bundle_id.sub_extension_id.is_none()
|
||||||
search_source_registry_tauri_state
|
{
|
||||||
.register_source(file_system_search)
|
let built_in_extension_dir = get_built_in_extension_directory(tauri_app_handle);
|
||||||
.await;
|
|
||||||
alter_extension_json_file(
|
let file_system_search = window_management::search_source::WindowManagementSearchSource;
|
||||||
&get_built_in_extension_directory(tauri_app_handle),
|
search_source_registry_tauri_state
|
||||||
bundle_id,
|
.register_source(file_system_search)
|
||||||
update_extension,
|
.await;
|
||||||
)?;
|
|
||||||
return Ok(());
|
let extension =
|
||||||
|
load_extension_from_json_file(&built_in_extension_dir, bundle_id.extension_id)?;
|
||||||
|
window_management::set_up_commands_hotkeys(tauri_app_handle, &extension)?;
|
||||||
|
|
||||||
|
alter_extension_json_file(&built_in_extension_dir, bundle_id, update_extension)?;
|
||||||
|
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
if bundle_id.extension_id == window_management::EXTENSION_ID {
|
||||||
|
if let Some(command_id) = bundle_id.sub_extension_id {
|
||||||
|
let built_in_extension_dir = get_built_in_extension_directory(tauri_app_handle);
|
||||||
|
alter_extension_json_file(&built_in_extension_dir, bundle_id, update_extension)?;
|
||||||
|
|
||||||
|
let extension =
|
||||||
|
load_extension_from_json_file(&built_in_extension_dir, bundle_id.extension_id)?;
|
||||||
|
window_management::set_up_command_hotkey(tauri_app_handle, &extension, command_id)?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -387,18 +443,44 @@ pub(crate) async fn disable_built_in_extension(
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if bundle_id.extension_id == file_search::EXTENSION_ID {
|
||||||
|
search_source_registry_tauri_state
|
||||||
|
.remove_source(bundle_id.extension_id)
|
||||||
|
.await;
|
||||||
|
alter_extension_json_file(
|
||||||
|
&get_built_in_extension_directory(tauri_app_handle),
|
||||||
|
bundle_id,
|
||||||
|
update_extension,
|
||||||
|
)?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
cfg_if::cfg_if! {
|
cfg_if::cfg_if! {
|
||||||
if #[cfg(any(target_os = "macos", target_os = "windows"))] {
|
if #[cfg(target_os = "macos")] {
|
||||||
if bundle_id.extension_id == file_search::EXTENSION_ID {
|
if bundle_id.extension_id == window_management::EXTENSION_ID
|
||||||
|
&& bundle_id.sub_extension_id.is_none()
|
||||||
|
{
|
||||||
|
let built_in_extension_dir = get_built_in_extension_directory(tauri_app_handle);
|
||||||
|
|
||||||
search_source_registry_tauri_state
|
search_source_registry_tauri_state
|
||||||
.remove_source(bundle_id.extension_id)
|
.remove_source(bundle_id.extension_id)
|
||||||
.await;
|
.await;
|
||||||
alter_extension_json_file(
|
alter_extension_json_file(&built_in_extension_dir, bundle_id, update_extension)?;
|
||||||
&get_built_in_extension_directory(tauri_app_handle),
|
|
||||||
bundle_id,
|
let extension =
|
||||||
update_extension,
|
load_extension_from_json_file(&built_in_extension_dir, bundle_id.extension_id)?;
|
||||||
)?;
|
window_management::unset_commands_hotkeys(tauri_app_handle, &extension)?;
|
||||||
return Ok(());
|
}
|
||||||
|
|
||||||
|
if bundle_id.extension_id == window_management::EXTENSION_ID {
|
||||||
|
if let Some(command_id) = bundle_id.sub_extension_id {
|
||||||
|
let built_in_extension_dir = get_built_in_extension_directory(tauri_app_handle);
|
||||||
|
alter_extension_json_file(&built_in_extension_dir, bundle_id, update_extension)?;
|
||||||
|
|
||||||
|
let extension =
|
||||||
|
load_extension_from_json_file(&built_in_extension_dir, bundle_id.extension_id)?;
|
||||||
|
window_management::unset_command_hotkey(tauri_app_handle, &extension, command_id)?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -410,12 +492,32 @@ pub(crate) fn set_built_in_extension_alias(
|
|||||||
tauri_app_handle: &AppHandle,
|
tauri_app_handle: &AppHandle,
|
||||||
bundle_id: &ExtensionBundleIdBorrowed<'_>,
|
bundle_id: &ExtensionBundleIdBorrowed<'_>,
|
||||||
alias: &str,
|
alias: &str,
|
||||||
) {
|
) -> Result<(), String> {
|
||||||
if bundle_id.extension_id == application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME {
|
if bundle_id.extension_id == application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME {
|
||||||
if let Some(app_path) = bundle_id.sub_extension_id {
|
if let Some(app_path) = bundle_id.sub_extension_id {
|
||||||
application::set_app_alias(tauri_app_handle, app_path, alias);
|
application::set_app_alias(tauri_app_handle, app_path, alias);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cfg_if::cfg_if! {
|
||||||
|
if #[cfg(target_os = "macos")] {
|
||||||
|
if bundle_id.extension_id == window_management::EXTENSION_ID
|
||||||
|
&& bundle_id.sub_extension_id.is_some()
|
||||||
|
{
|
||||||
|
let update_function = |ext: &mut Extension| {
|
||||||
|
ext.alias = Some(alias.to_string());
|
||||||
|
Ok(())
|
||||||
|
};
|
||||||
|
alter_extension_json_file(
|
||||||
|
&get_built_in_extension_directory(tauri_app_handle),
|
||||||
|
bundle_id,
|
||||||
|
update_function,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn register_built_in_extension_hotkey(
|
pub(crate) fn register_built_in_extension_hotkey(
|
||||||
@@ -428,6 +530,29 @@ pub(crate) fn register_built_in_extension_hotkey(
|
|||||||
application::register_app_hotkey(&tauri_app_handle, app_path, hotkey)?;
|
application::register_app_hotkey(&tauri_app_handle, app_path, hotkey)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cfg_if::cfg_if! {
|
||||||
|
if #[cfg(target_os = "macos")] {
|
||||||
|
let update_function = |ext: &mut Extension| {
|
||||||
|
ext.hotkey = Some(hotkey.into());
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
};
|
||||||
|
|
||||||
|
if bundle_id.extension_id == window_management::EXTENSION_ID {
|
||||||
|
if let Some(command_id) = bundle_id.sub_extension_id {
|
||||||
|
alter_extension_json_file(
|
||||||
|
&get_built_in_extension_directory(tauri_app_handle),
|
||||||
|
bundle_id,
|
||||||
|
update_function,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
window_management::register_command_hotkey(tauri_app_handle, command_id, hotkey)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -440,6 +565,35 @@ pub(crate) fn unregister_built_in_extension_hotkey(
|
|||||||
application::unregister_app_hotkey(&tauri_app_handle, app_path)?;
|
application::unregister_app_hotkey(&tauri_app_handle, app_path)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cfg_if::cfg_if! {
|
||||||
|
if #[cfg(target_os = "macos")] {
|
||||||
|
let update_function = |ext: &mut Extension| {
|
||||||
|
ext.hotkey = None;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
};
|
||||||
|
|
||||||
|
if bundle_id.extension_id == window_management::EXTENSION_ID {
|
||||||
|
if let Some(command_id) = bundle_id.sub_extension_id {
|
||||||
|
|
||||||
|
let extension = load_extension_from_json_file(
|
||||||
|
&get_built_in_extension_directory(tauri_app_handle),
|
||||||
|
bundle_id.extension_id,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
window_management::unregister_command_hotkey(tauri_app_handle, &extension, command_id)?;
|
||||||
|
alter_extension_json_file(
|
||||||
|
&get_built_in_extension_directory(tauri_app_handle),
|
||||||
|
bundle_id,
|
||||||
|
update_function,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -479,6 +633,8 @@ fn load_extension_from_json_file(
|
|||||||
Ok(extension)
|
Ok(extension)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(unused_macros)] // #[function_name::named] only used on macOS
|
||||||
|
#[function_name::named]
|
||||||
pub(crate) async fn is_built_in_extension_enabled(
|
pub(crate) async fn is_built_in_extension_enabled(
|
||||||
tauri_app_handle: &AppHandle,
|
tauri_app_handle: &AppHandle,
|
||||||
bundle_id: &ExtensionBundleIdBorrowed<'_>,
|
bundle_id: &ExtensionBundleIdBorrowed<'_>,
|
||||||
@@ -524,9 +680,17 @@ pub(crate) async fn is_built_in_extension_enabled(
|
|||||||
return Ok(extension.enabled);
|
return Ok(extension.enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if bundle_id.extension_id == file_search::EXTENSION_ID && bundle_id.sub_extension_id.is_none() {
|
||||||
|
return Ok(search_source_registry_tauri_state
|
||||||
|
.get_source(bundle_id.extension_id)
|
||||||
|
.await
|
||||||
|
.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
cfg_if::cfg_if! {
|
cfg_if::cfg_if! {
|
||||||
if #[cfg(any(target_os = "macos", target_os = "windows"))] {
|
if #[cfg(target_os = "macos")] {
|
||||||
if bundle_id.extension_id == file_search::EXTENSION_ID
|
// Window Management
|
||||||
|
if bundle_id.extension_id == window_management::EXTENSION_ID
|
||||||
&& bundle_id.sub_extension_id.is_none()
|
&& bundle_id.sub_extension_id.is_none()
|
||||||
{
|
{
|
||||||
return Ok(search_source_registry_tauri_state
|
return Ok(search_source_registry_tauri_state
|
||||||
@@ -534,6 +698,25 @@ pub(crate) async fn is_built_in_extension_enabled(
|
|||||||
.await
|
.await
|
||||||
.is_some());
|
.is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Window Management commands
|
||||||
|
if bundle_id.extension_id == window_management::EXTENSION_ID
|
||||||
|
&& let Some(command_id) = bundle_id.sub_extension_id
|
||||||
|
{
|
||||||
|
let extension = load_extension_from_json_file(
|
||||||
|
&get_built_in_extension_directory(tauri_app_handle),
|
||||||
|
bundle_id.extension_id,
|
||||||
|
)?;
|
||||||
|
let commands = extension
|
||||||
|
.commands
|
||||||
|
.expect("window management extension has commands");
|
||||||
|
|
||||||
|
let extension = commands.iter().find( |cmd| cmd.id == command_id).unwrap_or_else(|| {
|
||||||
|
panic!("function [{}()] invoked with a Window Management command that does not exist, extension ID [{}] ", function_name!(), command_id)
|
||||||
|
});
|
||||||
|
|
||||||
|
return Ok(extension.enabled);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
134
src-tauri/src/extension/built_in/window_management/actions.rs
Normal file
134
src-tauri/src/extension/built_in/window_management/actions.rs
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
#[derive(Debug, Clone, PartialEq, Copy, Hash, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub enum Action {
|
||||||
|
/// Move the window to fill left half of the screen.
|
||||||
|
TopHalf,
|
||||||
|
/// Move the window to fill bottom half of the screen.
|
||||||
|
BottomHalf,
|
||||||
|
/// Move the window to fill left half of the screen.
|
||||||
|
LeftHalf,
|
||||||
|
/// Move the window to fill right half of the screen.
|
||||||
|
RightHalf,
|
||||||
|
/// Move the window to fill center half of the screen.
|
||||||
|
CenterHalf,
|
||||||
|
|
||||||
|
/// Resize window to the top left quarter of the screen.
|
||||||
|
TopLeftQuarter,
|
||||||
|
/// Resize window to the top right quarter of the screen.
|
||||||
|
TopRightQuarter,
|
||||||
|
/// Resize window to the bottom left quarter of the screen.
|
||||||
|
BottomLeftQuarter,
|
||||||
|
/// Resize window to the bottom right quarter of the screen.
|
||||||
|
BottomRightQuarter,
|
||||||
|
|
||||||
|
/// Resize window to the top left sixth of the screen.
|
||||||
|
TopLeftSixth,
|
||||||
|
/// Resize window to the top center sixth of the screen.
|
||||||
|
TopCenterSixth,
|
||||||
|
/// Resize window to the top right sixth of the screen.
|
||||||
|
TopRightSixth,
|
||||||
|
/// Resize window to the bottom left sixth of the screen.
|
||||||
|
BottomLeftSixth,
|
||||||
|
/// Resize window to the bottom center sixth of the screen.
|
||||||
|
BottomCenterSixth,
|
||||||
|
/// Resize window to the bottom right sixth of the screen.
|
||||||
|
BottomRightSixth,
|
||||||
|
|
||||||
|
/// Resize window to the top third of the screen.
|
||||||
|
TopThird,
|
||||||
|
/// Resize window to the middle third of the screen.
|
||||||
|
MiddleThird,
|
||||||
|
/// Resize window to the bottom third of the screen.
|
||||||
|
BottomThird,
|
||||||
|
|
||||||
|
/// Center window in the screen.
|
||||||
|
Center,
|
||||||
|
|
||||||
|
/// Resize window to the first fourth of the screen.
|
||||||
|
FirstFourth,
|
||||||
|
/// Resize window to the second fourth of the screen.
|
||||||
|
SecondFourth,
|
||||||
|
/// Resize window to the third fourth of the screen.
|
||||||
|
ThirdFourth,
|
||||||
|
/// Resize window to the last fourth of the screen.
|
||||||
|
LastFourth,
|
||||||
|
|
||||||
|
/// Resize window to the first third of the screen.
|
||||||
|
FirstThird,
|
||||||
|
/// Resize window to the center third of the screen.
|
||||||
|
CenterThird,
|
||||||
|
/// Resize window to the last third of the screen.
|
||||||
|
LastThird,
|
||||||
|
|
||||||
|
/// Resize window to the first two thirds of the screen.
|
||||||
|
FirstTwoThirds,
|
||||||
|
/// Resize window to the center two thirds of the screen.
|
||||||
|
CenterTwoThirds,
|
||||||
|
/// Resize window to the last two thirds of the screen.
|
||||||
|
LastTwoThirds,
|
||||||
|
|
||||||
|
/// Resize window to the first three fourths of the screen.
|
||||||
|
FirstThreeFourths,
|
||||||
|
/// Resize window to the center three fourths of the screen.
|
||||||
|
CenterThreeFourths,
|
||||||
|
/// Resize window to the last three fourths of the screen.
|
||||||
|
LastThreeFourths,
|
||||||
|
|
||||||
|
/// Resize window to the top three fourths of the screen.
|
||||||
|
TopThreeFourths,
|
||||||
|
/// Resize window to the bottom three fourths of the screen.
|
||||||
|
BottomThreeFourths,
|
||||||
|
|
||||||
|
/// Resize window to the top two thirds of the screen.
|
||||||
|
TopTwoThirds,
|
||||||
|
/// Resize window to the bottom two thirds of the screen.
|
||||||
|
BottomTwoThirds,
|
||||||
|
/// Resize window to the top center two thirds of the screen.
|
||||||
|
TopCenterTwoThirds,
|
||||||
|
|
||||||
|
/// Resize window to the top first fourth of the screen.
|
||||||
|
TopFirstFourth,
|
||||||
|
/// Resize window to the top second fourth of the screen.
|
||||||
|
TopSecondFourth,
|
||||||
|
/// Resize window to the top third fourth of the screen.
|
||||||
|
TopThirdFourth,
|
||||||
|
/// Resize window to the top last fourth of the screen.
|
||||||
|
TopLastFourth,
|
||||||
|
|
||||||
|
/// Increase the window until it reaches the screen size.
|
||||||
|
MakeLarger,
|
||||||
|
/// Decrease the window until it reaches its minimal size.
|
||||||
|
MakeSmaller,
|
||||||
|
|
||||||
|
/// Maximize window to almost fit the screen.
|
||||||
|
AlmostMaximize,
|
||||||
|
/// Maximize window to fit the screen.
|
||||||
|
Maximize,
|
||||||
|
/// Maximize width of window to fit the screen.
|
||||||
|
MaximizeWidth,
|
||||||
|
/// Maximize height of window to fit the screen.
|
||||||
|
MaximizeHeight,
|
||||||
|
|
||||||
|
/// Move window to the top edge of the screen.
|
||||||
|
MoveUp,
|
||||||
|
/// Move window to the bottom of the screen.
|
||||||
|
MoveDown,
|
||||||
|
/// Move window to the left edge of the screen.
|
||||||
|
MoveLeft,
|
||||||
|
/// Move window to the right edge of the screen.
|
||||||
|
MoveRight,
|
||||||
|
|
||||||
|
/// Move window to the next desktop.
|
||||||
|
NextDesktop,
|
||||||
|
/// Move window to the previous desktop.
|
||||||
|
PreviousDesktop,
|
||||||
|
/// Move window to the next display.
|
||||||
|
NextDisplay,
|
||||||
|
/// Move window to the previous display.
|
||||||
|
PreviousDisplay,
|
||||||
|
|
||||||
|
/// Restore window to its last position.
|
||||||
|
Restore,
|
||||||
|
|
||||||
|
/// Toggle fullscreen mode.
|
||||||
|
ToggleFullscreen,
|
||||||
|
}
|
||||||
@@ -0,0 +1,638 @@
|
|||||||
|
//! This module calls macOS APIs to implement various helper functions needed by
|
||||||
|
//! to perform the defined actions.
|
||||||
|
|
||||||
|
mod private;
|
||||||
|
|
||||||
|
use std::ffi::c_uint;
|
||||||
|
use std::ffi::c_ushort;
|
||||||
|
use std::ffi::c_void;
|
||||||
|
use std::ops::Deref;
|
||||||
|
use std::ptr::NonNull;
|
||||||
|
|
||||||
|
use objc2::MainThreadMarker;
|
||||||
|
use objc2_app_kit::NSEvent;
|
||||||
|
use objc2_app_kit::NSScreen;
|
||||||
|
use objc2_app_kit::NSWorkspace;
|
||||||
|
use objc2_application_services::AXError;
|
||||||
|
use objc2_application_services::AXUIElement;
|
||||||
|
use objc2_application_services::AXValue;
|
||||||
|
use objc2_application_services::AXValueType;
|
||||||
|
use objc2_core_foundation::CFBoolean;
|
||||||
|
use objc2_core_foundation::CFRetained;
|
||||||
|
use objc2_core_foundation::CFString;
|
||||||
|
use objc2_core_foundation::CFType;
|
||||||
|
use objc2_core_foundation::CGPoint;
|
||||||
|
use objc2_core_foundation::CGRect;
|
||||||
|
use objc2_core_foundation::CGSize;
|
||||||
|
use objc2_core_foundation::Type;
|
||||||
|
use objc2_core_foundation::{CFArray, CFDictionary, CFNumber};
|
||||||
|
use objc2_core_graphics::CGError;
|
||||||
|
use objc2_core_graphics::CGEvent;
|
||||||
|
use objc2_core_graphics::CGEventFlags;
|
||||||
|
use objc2_core_graphics::CGEventTapLocation;
|
||||||
|
use objc2_core_graphics::CGEventType;
|
||||||
|
use objc2_core_graphics::CGMouseButton;
|
||||||
|
use objc2_core_graphics::CGRectGetMidX;
|
||||||
|
use objc2_core_graphics::CGRectGetMinY;
|
||||||
|
use objc2_core_graphics::CGWindowID;
|
||||||
|
|
||||||
|
use super::error::Error;
|
||||||
|
|
||||||
|
use private::CGSCopyManagedDisplaySpaces;
|
||||||
|
use private::CGSGetActiveSpace;
|
||||||
|
use private::CGSMainConnectionID;
|
||||||
|
use private::CGSSpaceID;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::{LazyLock, Mutex};
|
||||||
|
|
||||||
|
fn intersects(r1: CGRect, r2: CGRect) -> bool {
|
||||||
|
let overlapping = !(r1.origin.x + r1.size.width < r2.origin.x
|
||||||
|
|| r1.origin.y + r1.size.height < r2.origin.y
|
||||||
|
|| r1.origin.x > r2.origin.x + r2.size.width
|
||||||
|
|| r1.origin.y > r2.origin.y + r2.size.height);
|
||||||
|
|
||||||
|
overlapping
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Core graphics APIs use flipped coordinate system, while AppKit uses the
|
||||||
|
/// unflippled version, they differ in the y-axis. We need to do the conversion
|
||||||
|
/// (to `CGPoint.y`) manually.
|
||||||
|
fn flip_frame_y(main_screen_height: f64, frame_height: f64, frame_unflipped_y: f64) -> f64 {
|
||||||
|
main_screen_height - (frame_unflipped_y + frame_height)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper function to extract an UI element's origin.
|
||||||
|
fn get_ui_element_origin(ui_element: &CFRetained<AXUIElement>) -> Result<CGPoint, Error> {
|
||||||
|
let mut position_value: *const CFType = std::ptr::null();
|
||||||
|
let ptr_to_position_value = NonNull::new(&mut position_value).unwrap();
|
||||||
|
let position_attr = CFString::from_static_str("AXPosition");
|
||||||
|
let error = unsafe { ui_element.copy_attribute_value(&position_attr, ptr_to_position_value) };
|
||||||
|
|
||||||
|
if error != AXError::Success {
|
||||||
|
return Err(Error::AXError(error));
|
||||||
|
}
|
||||||
|
assert!(!position_value.is_null());
|
||||||
|
|
||||||
|
let position: CFRetained<AXValue> =
|
||||||
|
unsafe { CFRetained::from_raw(NonNull::new(position_value.cast_mut().cast()).unwrap()) };
|
||||||
|
|
||||||
|
let mut position_cg_point = CGPoint::ZERO;
|
||||||
|
let ptr_to_position_cg_point =
|
||||||
|
NonNull::new((&mut position_cg_point as *mut CGPoint).cast()).unwrap();
|
||||||
|
|
||||||
|
let result = unsafe { position.value(AXValueType::CGPoint, ptr_to_position_cg_point) };
|
||||||
|
assert!(result, "type mismatched");
|
||||||
|
|
||||||
|
Ok(position_cg_point)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper function to extract an UI element's size.
|
||||||
|
fn get_ui_element_size(ui_element: &CFRetained<AXUIElement>) -> Result<CGSize, Error> {
|
||||||
|
let mut size_value: *const CFType = std::ptr::null();
|
||||||
|
let ptr_to_size_value = NonNull::new(&mut size_value).unwrap();
|
||||||
|
let size_attr = CFString::from_static_str("AXSize");
|
||||||
|
let error = unsafe { ui_element.copy_attribute_value(&size_attr, ptr_to_size_value) };
|
||||||
|
|
||||||
|
if error != AXError::Success {
|
||||||
|
return Err(Error::AXError(error));
|
||||||
|
}
|
||||||
|
assert!(!size_value.is_null());
|
||||||
|
|
||||||
|
let size: CFRetained<AXValue> =
|
||||||
|
unsafe { CFRetained::from_raw(NonNull::new(size_value.cast_mut().cast()).unwrap()) };
|
||||||
|
|
||||||
|
let mut size_cg_size = CGSize::ZERO;
|
||||||
|
let ptr_to_size_cg_size = NonNull::new((&mut size_cg_size as *mut CGSize).cast()).unwrap();
|
||||||
|
|
||||||
|
let result = unsafe { size.value(AXValueType::CGSize, ptr_to_size_cg_size) };
|
||||||
|
assert!(result, "type mismatched");
|
||||||
|
|
||||||
|
Ok(size_cg_size)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the frontmost/focused window (as an UI element).
|
||||||
|
fn get_frontmost_window() -> Result<CFRetained<AXUIElement>, Error> {
|
||||||
|
let workspace = unsafe { NSWorkspace::sharedWorkspace() };
|
||||||
|
let frontmost_app =
|
||||||
|
unsafe { workspace.frontmostApplication() }.ok_or(Error::CannotFindFocusWindow)?;
|
||||||
|
|
||||||
|
let pid = unsafe { frontmost_app.processIdentifier() };
|
||||||
|
|
||||||
|
let app_element = unsafe { AXUIElement::new_application(pid) };
|
||||||
|
|
||||||
|
let mut window_element: *const CFType = std::ptr::null();
|
||||||
|
let ptr_to_window_element = NonNull::new(&mut window_element).unwrap();
|
||||||
|
let focused_window_attr = CFString::from_static_str("AXFocusedWindow");
|
||||||
|
|
||||||
|
let error =
|
||||||
|
unsafe { app_element.copy_attribute_value(&focused_window_attr, ptr_to_window_element) };
|
||||||
|
|
||||||
|
if error != AXError::Success {
|
||||||
|
return Err(Error::AXError(error));
|
||||||
|
}
|
||||||
|
assert!(!window_element.is_null());
|
||||||
|
|
||||||
|
let window_element: *mut AXUIElement = window_element.cast::<AXUIElement>().cast_mut();
|
||||||
|
|
||||||
|
let window = unsafe { CFRetained::from_raw(NonNull::new(window_element).unwrap()) };
|
||||||
|
|
||||||
|
Ok(window)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the CGWindowID of the frontmost/focused window.
|
||||||
|
#[allow(unused)] // In case we need it in the future
|
||||||
|
pub(crate) fn get_frontmost_window_id() -> Result<CGWindowID, Error> {
|
||||||
|
let element = get_frontmost_window()?;
|
||||||
|
let ptr: NonNull<AXUIElement> = CFRetained::as_ptr(&element);
|
||||||
|
|
||||||
|
let mut window_id_buffer: CGWindowID = 0;
|
||||||
|
let error =
|
||||||
|
unsafe { private::_AXUIElementGetWindow(ptr.as_ptr(), &mut window_id_buffer as *mut _) };
|
||||||
|
if error != AXError::Success {
|
||||||
|
return Err(Error::AXError(error));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(window_id_buffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the workspace ID list grouped by display. For example, suppose you
|
||||||
|
/// have 2 displays and 10 workspaces (5 workspaces per display), then this
|
||||||
|
/// function might return something like:
|
||||||
|
///
|
||||||
|
/// ```text
|
||||||
|
/// [
|
||||||
|
/// [8, 11, 12, 13, 24],
|
||||||
|
/// [519, 77, 15, 249, 414]
|
||||||
|
/// ]
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// Even though this function return macOS internal space IDs, they should correspond
|
||||||
|
/// to the logical workspace that users are familiar with. The display that contains
|
||||||
|
/// workspaces `[8, 11, 12, 13, 24]` should be your main display; workspace 8 represents
|
||||||
|
/// Desktop 1, and workspace 414 represents Desktop 10.
|
||||||
|
fn workspace_ids_grouped_by_display() -> Vec<Vec<CGSSpaceID>> {
|
||||||
|
unsafe {
|
||||||
|
let mut ret = Vec::new();
|
||||||
|
let conn = CGSMainConnectionID();
|
||||||
|
|
||||||
|
let display_spaces_raw = CGSCopyManagedDisplaySpaces(conn);
|
||||||
|
let display_spaces: CFRetained<CFArray> =
|
||||||
|
CFRetained::from_raw(NonNull::new(display_spaces_raw).unwrap());
|
||||||
|
|
||||||
|
let key_spaces: CFRetained<CFString> = CFString::from_static_str("Spaces");
|
||||||
|
let key_spaces_ptr: NonNull<CFString> = CFRetained::as_ptr(&key_spaces);
|
||||||
|
let key_id64: CFRetained<CFString> = CFString::from_static_str("id64");
|
||||||
|
let key_id64_ptr: NonNull<CFString> = CFRetained::as_ptr(&key_id64);
|
||||||
|
|
||||||
|
for i in 0..display_spaces.count() {
|
||||||
|
let mut workspaces_of_this_display = Vec::new();
|
||||||
|
|
||||||
|
let dict_ref = display_spaces.value_at_index(i);
|
||||||
|
let dict: &CFDictionary = &*(dict_ref as *const CFDictionary);
|
||||||
|
|
||||||
|
let mut ptr_to_value_buffer: *const c_void = std::ptr::null();
|
||||||
|
let key_exists = dict.value_if_present(
|
||||||
|
key_spaces_ptr.as_ptr().cast::<c_void>().cast_const(),
|
||||||
|
&mut ptr_to_value_buffer as *mut _,
|
||||||
|
);
|
||||||
|
assert!(key_exists);
|
||||||
|
assert!(!ptr_to_value_buffer.is_null());
|
||||||
|
|
||||||
|
let spaces_raw: *const CFArray = ptr_to_value_buffer.cast::<CFArray>();
|
||||||
|
|
||||||
|
let spaces = &*spaces_raw;
|
||||||
|
|
||||||
|
for idx in 0..spaces.count() {
|
||||||
|
let workspace_dictionary: &CFDictionary =
|
||||||
|
&*spaces.value_at_index(idx).cast::<CFDictionary>();
|
||||||
|
|
||||||
|
let mut ptr_to_value_buffer: *const c_void = std::ptr::null();
|
||||||
|
let key_exists = workspace_dictionary.value_if_present(
|
||||||
|
key_id64_ptr.as_ptr().cast::<c_void>().cast_const(),
|
||||||
|
&mut ptr_to_value_buffer as *mut _,
|
||||||
|
);
|
||||||
|
assert!(key_exists);
|
||||||
|
assert!(!ptr_to_value_buffer.is_null());
|
||||||
|
|
||||||
|
let ptr_workspace_id = ptr_to_value_buffer.cast::<CFNumber>();
|
||||||
|
let workspace_id = (&*ptr_workspace_id).as_i32().unwrap();
|
||||||
|
|
||||||
|
workspaces_of_this_display.push(workspace_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
ret.push(workspaces_of_this_display);
|
||||||
|
}
|
||||||
|
|
||||||
|
ret
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the next workspace's logical ID. By logical ID, we mean the ID that
|
||||||
|
/// users are familiar with, workspace 1/2/3 and so on, rather than the internal
|
||||||
|
/// `CGSSpaceID`.
|
||||||
|
///
|
||||||
|
/// NOTE that this function returns None when the current workspace is the last
|
||||||
|
/// workspace in the current display.
|
||||||
|
pub(crate) fn get_next_workspace_logical_id() -> Option<usize> {
|
||||||
|
let window_server_connection = unsafe { CGSMainConnectionID() };
|
||||||
|
let current_workspace_id = unsafe { CGSGetActiveSpace(window_server_connection) };
|
||||||
|
|
||||||
|
// Logical ID starts from 1
|
||||||
|
let mut logical_id = 1_usize;
|
||||||
|
|
||||||
|
for workspaces_in_a_display in workspace_ids_grouped_by_display() {
|
||||||
|
for (idx, workspace_raw_id) in workspaces_in_a_display.iter().enumerate() {
|
||||||
|
if *workspace_raw_id == current_workspace_id {
|
||||||
|
// We found it, now check if it is the last workspace in this display
|
||||||
|
if idx == workspaces_in_a_display.len() - 1 {
|
||||||
|
return None;
|
||||||
|
} else {
|
||||||
|
return Some(logical_id + 1);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logical_id += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
unreachable!(
|
||||||
|
"unless the private API CGSGetActiveSpace() is broken, it should return an ID that is in the workspace ID list"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the previous workspace's logical ID.
|
||||||
|
///
|
||||||
|
/// See [`get_next_workspace_logical_id`] for the doc.
|
||||||
|
pub(crate) fn get_previous_workspace_logical_id() -> Option<usize> {
|
||||||
|
let window_server_connection = unsafe { CGSMainConnectionID() };
|
||||||
|
let current_workspace_id = unsafe { CGSGetActiveSpace(window_server_connection) };
|
||||||
|
|
||||||
|
// Logical ID starts from 1
|
||||||
|
let mut logical_id = 1_usize;
|
||||||
|
|
||||||
|
for workspaces_in_a_display in workspace_ids_grouped_by_display() {
|
||||||
|
for (idx, workspace_raw_id) in workspaces_in_a_display.iter().enumerate() {
|
||||||
|
if *workspace_raw_id == current_workspace_id {
|
||||||
|
// We found it, now check if it is the first workspace in this display
|
||||||
|
if idx == 0 {
|
||||||
|
return None;
|
||||||
|
} else {
|
||||||
|
// this sub operation is safe, logical_id is at least 2
|
||||||
|
return Some(logical_id - 1);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logical_id += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
unreachable!(
|
||||||
|
"unless the private API CGSGetActiveSpace() is broken, it should return an ID that is in the workspace ID list"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move the frontmost window to the specified workspace.
|
||||||
|
///
|
||||||
|
/// Credits to the Silica library
|
||||||
|
///
|
||||||
|
/// * https://github.com/ianyh/Silica/blob/b91a18dbb822e99ce6b487d1cb4841e863139b2a/Silica/Sources/SIWindow.m#L215-L260
|
||||||
|
/// * https://github.com/ianyh/Silica/blob/b91a18dbb822e99ce6b487d1cb4841e863139b2a/Silica/Sources/SISystemWideElement.m#L29-L65
|
||||||
|
pub(crate) fn move_frontmost_window_to_workspace(space: usize) -> Result<(), Error> {
|
||||||
|
assert!(space >= 1);
|
||||||
|
if space > 16 {
|
||||||
|
return Err(Error::TooManyWorkspace);
|
||||||
|
}
|
||||||
|
|
||||||
|
let window_frame = get_frontmost_window_frame()?;
|
||||||
|
let close_button_frame = get_frontmost_window_close_button_frame()?;
|
||||||
|
|
||||||
|
let mouse_cursor_point = CGPoint::new(
|
||||||
|
unsafe { CGRectGetMidX(close_button_frame) },
|
||||||
|
window_frame.origin.y
|
||||||
|
+ (window_frame.origin.y - unsafe { CGRectGetMinY(close_button_frame) }).abs() / 2.0,
|
||||||
|
);
|
||||||
|
|
||||||
|
let mouse_move_event = unsafe {
|
||||||
|
CGEvent::new_mouse_event(
|
||||||
|
None,
|
||||||
|
CGEventType::MouseMoved,
|
||||||
|
mouse_cursor_point,
|
||||||
|
CGMouseButton::Left,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let mouse_drag_event = unsafe {
|
||||||
|
CGEvent::new_mouse_event(
|
||||||
|
None,
|
||||||
|
CGEventType::LeftMouseDragged,
|
||||||
|
mouse_cursor_point,
|
||||||
|
CGMouseButton::Left,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let mouse_down_event = unsafe {
|
||||||
|
CGEvent::new_mouse_event(
|
||||||
|
None,
|
||||||
|
CGEventType::LeftMouseDown,
|
||||||
|
mouse_cursor_point,
|
||||||
|
CGMouseButton::Left,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let mouse_up_event = unsafe {
|
||||||
|
CGEvent::new_mouse_event(
|
||||||
|
None,
|
||||||
|
CGEventType::LeftMouseUp,
|
||||||
|
mouse_cursor_point,
|
||||||
|
CGMouseButton::Left,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
CGEvent::set_flags(mouse_move_event.as_deref(), CGEventFlags(0));
|
||||||
|
CGEvent::set_flags(mouse_down_event.as_deref(), CGEventFlags(0));
|
||||||
|
CGEvent::set_flags(mouse_up_event.as_deref(), CGEventFlags(0));
|
||||||
|
|
||||||
|
// Move the mouse into place at the window's toolbar
|
||||||
|
CGEvent::post(CGEventTapLocation::HIDEventTap, mouse_move_event.as_deref());
|
||||||
|
// Mouse down to set up the drag
|
||||||
|
CGEvent::post(CGEventTapLocation::HIDEventTap, mouse_down_event.as_deref());
|
||||||
|
// Drag event to grab hold of the window
|
||||||
|
CGEvent::post(CGEventTapLocation::HIDEventTap, mouse_drag_event.as_deref());
|
||||||
|
}
|
||||||
|
|
||||||
|
// cast is safe as space is in range [1, 16]
|
||||||
|
let hot_key: c_ushort = 118 + space as c_ushort - 1;
|
||||||
|
|
||||||
|
let mut flags: c_uint = 0;
|
||||||
|
let mut key_code: c_ushort = 0;
|
||||||
|
let error = unsafe {
|
||||||
|
private::CGSGetSymbolicHotKeyValue(hot_key, std::ptr::null_mut(), &mut key_code, &mut flags)
|
||||||
|
};
|
||||||
|
if error != CGError::Success {
|
||||||
|
return Err(Error::CGError(error));
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
// If the hotkey is disabled, enable it.
|
||||||
|
if !private::CGSIsSymbolicHotKeyEnabled(hot_key) {
|
||||||
|
if private::CGSSetSymbolicHotKeyEnabled(hot_key, true) != CGError::Success {
|
||||||
|
return Err(Error::CGError(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let opt_keyboard_event = unsafe { CGEvent::new_keyboard_event(None, key_code, true) };
|
||||||
|
unsafe {
|
||||||
|
// cast is safe (uint -> u64)
|
||||||
|
CGEvent::set_flags(opt_keyboard_event.as_deref(), CGEventFlags(flags as u64));
|
||||||
|
}
|
||||||
|
|
||||||
|
let keyboard_event = opt_keyboard_event.unwrap();
|
||||||
|
let event = unsafe { NSEvent::eventWithCGEvent(&keyboard_event) }.unwrap();
|
||||||
|
|
||||||
|
let keyboard_event_up = unsafe { CGEvent::new_keyboard_event(None, event.keyCode(), false) };
|
||||||
|
unsafe {
|
||||||
|
CGEvent::set_flags(keyboard_event_up.as_deref(), CGEventFlags(0));
|
||||||
|
|
||||||
|
// Send the shortcut command to get Mission Control to switch spaces from under the window.
|
||||||
|
CGEvent::post(CGEventTapLocation::HIDEventTap, event.CGEvent().as_deref());
|
||||||
|
CGEvent::post(
|
||||||
|
CGEventTapLocation::HIDEventTap,
|
||||||
|
keyboard_event_up.as_deref(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
// Let go of the window.
|
||||||
|
CGEvent::post(CGEventTapLocation::HIDEventTap, mouse_up_event.as_deref());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn get_frontmost_window_origin() -> Result<CGPoint, Error> {
|
||||||
|
let frontmost_window = get_frontmost_window()?;
|
||||||
|
get_ui_element_origin(&frontmost_window)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn get_frontmost_window_size() -> Result<CGSize, Error> {
|
||||||
|
let frontmost_window = get_frontmost_window()?;
|
||||||
|
get_ui_element_size(&frontmost_window)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn get_frontmost_window_frame() -> Result<CGRect, Error> {
|
||||||
|
let origin = get_frontmost_window_origin()?;
|
||||||
|
let size = get_frontmost_window_size()?;
|
||||||
|
|
||||||
|
Ok(CGRect { origin, size })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the frontmost window's close button, then extract its frame.
|
||||||
|
fn get_frontmost_window_close_button_frame() -> Result<CGRect, Error> {
|
||||||
|
let window = get_frontmost_window()?;
|
||||||
|
|
||||||
|
let mut ptr_to_close_button: *const CFType = std::ptr::null();
|
||||||
|
let ptr_to_buffer = NonNull::new(&mut ptr_to_close_button).unwrap();
|
||||||
|
|
||||||
|
let close_button_attribute = CFString::from_static_str("AXCloseButton");
|
||||||
|
let error = unsafe { window.copy_attribute_value(&close_button_attribute, ptr_to_buffer) };
|
||||||
|
if error != AXError::Success {
|
||||||
|
return Err(Error::AXError(error));
|
||||||
|
}
|
||||||
|
assert!(!ptr_to_close_button.is_null());
|
||||||
|
|
||||||
|
let close_button_element = ptr_to_close_button.cast::<AXUIElement>().cast_mut();
|
||||||
|
let close_button = unsafe { CFRetained::from_raw(NonNull::new(close_button_element).unwrap()) };
|
||||||
|
|
||||||
|
let origin = get_ui_element_origin(&close_button)?;
|
||||||
|
let size = get_ui_element_size(&close_button)?;
|
||||||
|
|
||||||
|
Ok(CGRect { origin, size })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// This function returns the "visible frame" [^1] of all the screens.
|
||||||
|
///
|
||||||
|
/// FIXME: This function relies on the [`visibleFrame()`][vf_doc] API, which
|
||||||
|
/// has 2 bugs we need to work around:
|
||||||
|
///
|
||||||
|
/// 1. It assumes the Dock is on the main display, which in reality depends on
|
||||||
|
/// how users arrange their displays and the "Dock position on screen" setting
|
||||||
|
/// entry.
|
||||||
|
/// 2. For non-main displays, it assumes that they don't have a menu bar, but macOS
|
||||||
|
/// puts a menu bar on every display.
|
||||||
|
///
|
||||||
|
///
|
||||||
|
/// [^1]: Visible frame: a rectangle defines the portion of the screen in which it
|
||||||
|
/// is currently safe to draw your app’s content.
|
||||||
|
///
|
||||||
|
/// [vf_doc]: https://developer.apple.com/documentation/AppKit/NSScreen/visibleFrame
|
||||||
|
pub(crate) fn list_visible_frame_of_all_screens() -> Result<Vec<CGRect>, Error> {
|
||||||
|
let main_thread_marker = MainThreadMarker::new().ok_or(Error::NotInMainThread)?;
|
||||||
|
let screens = NSScreen::screens(main_thread_marker).to_vec();
|
||||||
|
|
||||||
|
if screens.is_empty() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
let main_screen = screens.first().expect("screens is not empty");
|
||||||
|
|
||||||
|
let frames = screens
|
||||||
|
.iter()
|
||||||
|
.map(|ns_screen| {
|
||||||
|
// NSScreen is an AppKit API, which uses unflipped coordinate
|
||||||
|
// system, flip it
|
||||||
|
let mut unflipped_frame = ns_screen.visibleFrame();
|
||||||
|
let flipped_frame_origin_y = flip_frame_y(
|
||||||
|
main_screen.frame().size.height,
|
||||||
|
unflipped_frame.size.height,
|
||||||
|
unflipped_frame.origin.y,
|
||||||
|
);
|
||||||
|
unflipped_frame.origin.y = flipped_frame_origin_y;
|
||||||
|
|
||||||
|
unflipped_frame
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(frames)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the Visible frame of the "active screen"[^1].
|
||||||
|
///
|
||||||
|
///
|
||||||
|
/// [^1]: the screen which the frontmost window is on.
|
||||||
|
pub(crate) fn get_active_screen_visible_frame() -> Result<CGRect, Error> {
|
||||||
|
let main_thread_marker = MainThreadMarker::new().ok_or(Error::NotInMainThread)?;
|
||||||
|
|
||||||
|
let frontmost_window_frame = get_frontmost_window_frame()?;
|
||||||
|
|
||||||
|
let screens = NSScreen::screens(main_thread_marker)
|
||||||
|
.into_iter()
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
if screens.is_empty() {
|
||||||
|
return Err(Error::NoDisplay);
|
||||||
|
}
|
||||||
|
|
||||||
|
let main_screen_height = screens[0].frame().size.height;
|
||||||
|
|
||||||
|
// AppKit uses Unflipped Coordinate System, but Accessibility APIs use
|
||||||
|
// Flipped Coordinate System, we need to flip the origin of these screens.
|
||||||
|
for screen in screens {
|
||||||
|
let mut screen_frame = screen.frame();
|
||||||
|
let unflipped_y = screen_frame.origin.y;
|
||||||
|
let flipped_y = flip_frame_y(main_screen_height, screen_frame.size.height, unflipped_y);
|
||||||
|
screen_frame.origin.y = flipped_y;
|
||||||
|
|
||||||
|
if intersects(screen_frame, frontmost_window_frame) {
|
||||||
|
let mut visible_frame = screen.visibleFrame();
|
||||||
|
let flipped_y = flip_frame_y(
|
||||||
|
main_screen_height,
|
||||||
|
visible_frame.size.height,
|
||||||
|
visible_frame.origin.y,
|
||||||
|
);
|
||||||
|
visible_frame.origin.y = flipped_y;
|
||||||
|
|
||||||
|
return Ok(visible_frame);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
unreachable!()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move the frontmost window's origin to the point specified by `x` and `y`.
|
||||||
|
pub fn move_frontmost_window(x: f64, y: f64) -> Result<(), Error> {
|
||||||
|
let frontmost_window = get_frontmost_window()?;
|
||||||
|
|
||||||
|
let mut point = CGPoint::new(x, y);
|
||||||
|
let ptr_to_point = NonNull::new((&mut point as *mut CGPoint).cast::<c_void>()).unwrap();
|
||||||
|
let pos_value = unsafe { AXValue::new(AXValueType::CGPoint, ptr_to_point) }.unwrap();
|
||||||
|
let pos_attr = CFString::from_static_str("AXPosition");
|
||||||
|
|
||||||
|
let error = unsafe { frontmost_window.set_attribute_value(&pos_attr, pos_value.deref()) };
|
||||||
|
if error != AXError::Success {
|
||||||
|
return Err(Error::AXError(error));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the frontmost window's frame to the specified frame - adjust size and
|
||||||
|
/// location at the same time.
|
||||||
|
pub fn set_frontmost_window_frame(frame: CGRect) -> Result<(), Error> {
|
||||||
|
let frontmost_window = get_frontmost_window()?;
|
||||||
|
|
||||||
|
let mut point = frame.origin;
|
||||||
|
let ptr_to_point = NonNull::new((&mut point as *mut CGPoint).cast::<c_void>()).unwrap();
|
||||||
|
let pos_value = unsafe { AXValue::new(AXValueType::CGPoint, ptr_to_point) }.unwrap();
|
||||||
|
let pos_attr = CFString::from_static_str("AXPosition");
|
||||||
|
|
||||||
|
let error = unsafe { frontmost_window.set_attribute_value(&pos_attr, pos_value.deref()) };
|
||||||
|
if error != AXError::Success {
|
||||||
|
return Err(Error::AXError(error));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut size = frame.size;
|
||||||
|
let ptr_to_size = NonNull::new((&mut size as *mut CGSize).cast::<c_void>()).unwrap();
|
||||||
|
let size_value = unsafe { AXValue::new(AXValueType::CGSize, ptr_to_size) }.unwrap();
|
||||||
|
let size_attr = CFString::from_static_str("AXSize");
|
||||||
|
|
||||||
|
let error = unsafe { frontmost_window.set_attribute_value(&size_attr, size_value.deref()) };
|
||||||
|
if error != AXError::Success {
|
||||||
|
return Err(Error::AXError(error));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn toggle_fullscreen() -> Result<(), Error> {
|
||||||
|
let frontmost_window = get_frontmost_window()?;
|
||||||
|
let fullscreen_attr = CFString::from_static_str("AXFullScreen");
|
||||||
|
|
||||||
|
let mut current_value_ref: *const CFType = std::ptr::null();
|
||||||
|
let error = unsafe {
|
||||||
|
frontmost_window.copy_attribute_value(
|
||||||
|
&fullscreen_attr,
|
||||||
|
NonNull::new(&mut current_value_ref).unwrap(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
// TODO: If the attribute doesn't exist, error won't be Success as well.
|
||||||
|
// Before we handle that, we need to know the error case that will be
|
||||||
|
// returned in that case.
|
||||||
|
if error != AXError::Success {
|
||||||
|
return Err(Error::AXError(error));
|
||||||
|
}
|
||||||
|
assert!(!current_value_ref.is_null());
|
||||||
|
|
||||||
|
let current_value = unsafe {
|
||||||
|
let retained_boolean: CFRetained<CFBoolean> = CFRetained::from_raw(
|
||||||
|
NonNull::new(current_value_ref.cast::<CFBoolean>().cast_mut()).unwrap(),
|
||||||
|
);
|
||||||
|
retained_boolean.as_bool()
|
||||||
|
};
|
||||||
|
|
||||||
|
let new_value = !current_value;
|
||||||
|
let new_value_ref: CFRetained<CFBoolean> = CFBoolean::new(new_value).retain();
|
||||||
|
|
||||||
|
let error =
|
||||||
|
unsafe { frontmost_window.set_attribute_value(&fullscreen_attr, new_value_ref.deref()) };
|
||||||
|
|
||||||
|
if error != AXError::Success {
|
||||||
|
return Err(Error::AXError(error));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
static LAST_FRAME: LazyLock<Mutex<HashMap<CGWindowID, CGRect>>> =
|
||||||
|
LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||||
|
|
||||||
|
pub(crate) fn set_frontmost_window_last_frame(window_id: CGWindowID, frame: CGRect) {
|
||||||
|
let mut map = LAST_FRAME.lock().unwrap();
|
||||||
|
map.insert(window_id, frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn get_frontmost_window_last_frame(window_id: CGWindowID) -> Option<CGRect> {
|
||||||
|
let map = LAST_FRAME.lock().unwrap();
|
||||||
|
map.get(&window_id).cloned()
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
//! Private macOS APIs.
|
||||||
|
|
||||||
|
use bitflags::bitflags;
|
||||||
|
use objc2_application_services::AXError;
|
||||||
|
use objc2_application_services::AXUIElement;
|
||||||
|
use objc2_core_foundation::CFArray;
|
||||||
|
use objc2_core_graphics::CGError;
|
||||||
|
use objc2_core_graphics::CGWindowID;
|
||||||
|
use std::ffi::c_int;
|
||||||
|
use std::ffi::c_uint;
|
||||||
|
use std::ffi::c_ushort;
|
||||||
|
|
||||||
|
pub(crate) type CGSConnectionID = u32;
|
||||||
|
pub(crate) type CGSSpaceID = c_int;
|
||||||
|
|
||||||
|
bitflags! {
|
||||||
|
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
|
||||||
|
#[repr(transparent)]
|
||||||
|
pub struct CGSSpaceMask: c_int {
|
||||||
|
const INCLUDE_CURRENT = 1 << 0;
|
||||||
|
const INCLUDE_OTHERS = 1 << 1;
|
||||||
|
|
||||||
|
const INCLUDE_USER = 1 << 2;
|
||||||
|
const INCLUDE_OS = 1 << 3;
|
||||||
|
|
||||||
|
const VISIBLE = 1 << 16;
|
||||||
|
|
||||||
|
const CURRENT_SPACES = Self::INCLUDE_USER.bits() | Self::INCLUDE_CURRENT.bits();
|
||||||
|
const OTHER_SPACES = Self::INCLUDE_USER.bits() | Self::INCLUDE_OTHERS.bits();
|
||||||
|
const ALL_SPACES =
|
||||||
|
Self::INCLUDE_USER.bits() | Self::INCLUDE_OTHERS.bits() | Self::INCLUDE_CURRENT.bits();
|
||||||
|
|
||||||
|
const ALL_VISIBLE_SPACES = Self::ALL_SPACES.bits() | Self::VISIBLE.bits();
|
||||||
|
|
||||||
|
const CURRENT_OS_SPACES = Self::INCLUDE_OS.bits() | Self::INCLUDE_CURRENT.bits();
|
||||||
|
const OTHER_OS_SPACES = Self::INCLUDE_OS.bits() | Self::INCLUDE_OTHERS.bits();
|
||||||
|
const ALL_OS_SPACES =
|
||||||
|
Self::INCLUDE_OS.bits() | Self::INCLUDE_OTHERS.bits() | Self::INCLUDE_CURRENT.bits();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe extern "C" {
|
||||||
|
/// Extract `window_id` from an AXUIElement.
|
||||||
|
pub(crate) fn _AXUIElementGetWindow(
|
||||||
|
elem: *mut AXUIElement,
|
||||||
|
window_id: *mut CGWindowID,
|
||||||
|
) -> AXError;
|
||||||
|
|
||||||
|
/// Connect to the WindowServer and get a connection descriptor.
|
||||||
|
pub(crate) fn CGSMainConnectionID() -> CGSConnectionID;
|
||||||
|
|
||||||
|
/// It returns a CFArray of dictionaries. Each dictionary contains information
|
||||||
|
/// about a display, including a list of all the spaces (CGSSpaceID) on that display.
|
||||||
|
pub(crate) fn CGSCopyManagedDisplaySpaces(cid: CGSConnectionID) -> *mut CFArray;
|
||||||
|
|
||||||
|
/// Gets the ID of the space currently visible to the user.
|
||||||
|
pub(crate) fn CGSGetActiveSpace(cid: CGSConnectionID) -> CGSSpaceID;
|
||||||
|
|
||||||
|
/// Returns the values the symbolic hot key represented by the given UID is configured with.
|
||||||
|
pub(crate) fn CGSGetSymbolicHotKeyValue(
|
||||||
|
hotKey: c_ushort,
|
||||||
|
outKeyEquivalent: *mut c_ushort,
|
||||||
|
outVirtualKeyCode: *mut c_ushort,
|
||||||
|
outModifiers: *mut c_uint,
|
||||||
|
) -> CGError;
|
||||||
|
/// Returns whether the symbolic hot key represented by the given UID is enabled.
|
||||||
|
pub(crate) fn CGSIsSymbolicHotKeyEnabled(hotKey: c_ushort) -> bool;
|
||||||
|
/// Sets whether the symbolic hot key represented by the given UID is enabled.
|
||||||
|
pub(crate) fn CGSSetSymbolicHotKeyEnabled(hotKey: c_ushort, isEnabled: bool) -> CGError;
|
||||||
|
}
|
||||||
25
src-tauri/src/extension/built_in/window_management/error.rs
Normal file
25
src-tauri/src/extension/built_in/window_management/error.rs
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
use objc2_application_services::AXError;
|
||||||
|
use objc2_core_graphics::CGError;
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum Error {
|
||||||
|
/// Cannot find the focused window.
|
||||||
|
#[error("Cannot find the focused window.")]
|
||||||
|
CannotFindFocusWindow,
|
||||||
|
/// Error code from the macOS Accessibility APIs.
|
||||||
|
#[error("Error code from the macOS Accessibility APIs: {0:?}")]
|
||||||
|
AXError(AXError),
|
||||||
|
/// Function should be in called from the main thread, but it is not.
|
||||||
|
#[error("Function should be in called from the main thread, but it is not.")]
|
||||||
|
NotInMainThread,
|
||||||
|
/// No monitor detected.
|
||||||
|
#[error("No monitor detected.")]
|
||||||
|
NoDisplay,
|
||||||
|
/// Can only handle 16 Workspaces at most.
|
||||||
|
#[error("libwmgr can only handle 16 Workspaces at most.")]
|
||||||
|
TooManyWorkspace,
|
||||||
|
/// Error code from the macOS Core Graphics APIs.
|
||||||
|
#[error("Error code from the macOS Core Graphics APIs: {0:?}")]
|
||||||
|
CGError(CGError),
|
||||||
|
}
|
||||||
973
src-tauri/src/extension/built_in/window_management/mod.rs
Normal file
973
src-tauri/src/extension/built_in/window_management/mod.rs
Normal file
@@ -0,0 +1,973 @@
|
|||||||
|
pub(crate) mod actions;
|
||||||
|
mod backend;
|
||||||
|
mod error;
|
||||||
|
pub(crate) mod on_opened;
|
||||||
|
pub(crate) mod search_source;
|
||||||
|
|
||||||
|
use crate::common::document::open;
|
||||||
|
use crate::extension::Extension;
|
||||||
|
use actions::Action;
|
||||||
|
use backend::get_active_screen_visible_frame;
|
||||||
|
use backend::get_frontmost_window_frame;
|
||||||
|
use backend::get_frontmost_window_id;
|
||||||
|
use backend::get_frontmost_window_last_frame;
|
||||||
|
use backend::get_next_workspace_logical_id;
|
||||||
|
use backend::get_previous_workspace_logical_id;
|
||||||
|
use backend::list_visible_frame_of_all_screens;
|
||||||
|
use backend::move_frontmost_window;
|
||||||
|
use backend::move_frontmost_window_to_workspace;
|
||||||
|
use backend::set_frontmost_window_frame;
|
||||||
|
use backend::set_frontmost_window_last_frame;
|
||||||
|
use backend::toggle_fullscreen;
|
||||||
|
use error::Error;
|
||||||
|
use objc2_core_foundation::{CGPoint, CGRect, CGSize};
|
||||||
|
use oneshot::channel as oneshot_channel;
|
||||||
|
use tauri::AppHandle;
|
||||||
|
use tauri::async_runtime;
|
||||||
|
use tauri_plugin_global_shortcut::GlobalShortcutExt;
|
||||||
|
use tauri_plugin_global_shortcut::ShortcutState;
|
||||||
|
|
||||||
|
pub(crate) const EXTENSION_ID: &str = "Window Management";
|
||||||
|
|
||||||
|
/// JSON file for this extension.
|
||||||
|
pub(crate) const PLUGIN_JSON_FILE: &str = include_str!("./plugin.json");
|
||||||
|
|
||||||
|
pub(crate) fn perform_action_on_main_thread(
|
||||||
|
tauri_app_handle: &AppHandle,
|
||||||
|
action: Action,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let (tx, rx) = oneshot_channel();
|
||||||
|
|
||||||
|
tauri_app_handle
|
||||||
|
.run_on_main_thread(move || {
|
||||||
|
let res = perform_action(action).map_err(|e| e.to_string());
|
||||||
|
tx.send(res)
|
||||||
|
.expect("oneshot channel receiver unexpectedly dropped");
|
||||||
|
})
|
||||||
|
.expect("tauri internal bug, channel receiver dropped");
|
||||||
|
|
||||||
|
rx.recv()
|
||||||
|
.expect("oneshot channel sender unexpectedly dropped before sending function return value")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Perform this action to the focused window.
|
||||||
|
fn perform_action(action: Action) -> Result<(), Error> {
|
||||||
|
let visible_frame = get_active_screen_visible_frame()?;
|
||||||
|
let frontmost_window_id = get_frontmost_window_id()?;
|
||||||
|
let frontmost_window_frame = get_frontmost_window_frame()?;
|
||||||
|
|
||||||
|
set_frontmost_window_last_frame(frontmost_window_id, frontmost_window_frame);
|
||||||
|
|
||||||
|
match action {
|
||||||
|
Action::TopHalf => {
|
||||||
|
let origin = CGPoint {
|
||||||
|
x: visible_frame.origin.x,
|
||||||
|
y: visible_frame.origin.y,
|
||||||
|
};
|
||||||
|
let size = CGSize {
|
||||||
|
width: visible_frame.size.width,
|
||||||
|
height: visible_frame.size.height / 2.0,
|
||||||
|
};
|
||||||
|
let new_frame = CGRect { origin, size };
|
||||||
|
set_frontmost_window_frame(new_frame)
|
||||||
|
}
|
||||||
|
Action::BottomHalf => {
|
||||||
|
let origin = CGPoint {
|
||||||
|
x: visible_frame.origin.x,
|
||||||
|
y: visible_frame.origin.y + visible_frame.size.height / 2.0,
|
||||||
|
};
|
||||||
|
let size = CGSize {
|
||||||
|
width: visible_frame.size.width,
|
||||||
|
height: visible_frame.size.height / 2.0,
|
||||||
|
};
|
||||||
|
let new_frame = CGRect { origin, size };
|
||||||
|
set_frontmost_window_frame(new_frame)
|
||||||
|
}
|
||||||
|
Action::LeftHalf => {
|
||||||
|
let origin = CGPoint {
|
||||||
|
x: visible_frame.origin.x,
|
||||||
|
y: visible_frame.origin.y,
|
||||||
|
};
|
||||||
|
let size = CGSize {
|
||||||
|
width: visible_frame.size.width / 2.0,
|
||||||
|
height: visible_frame.size.height,
|
||||||
|
};
|
||||||
|
let new_frame = CGRect { origin, size };
|
||||||
|
set_frontmost_window_frame(new_frame)
|
||||||
|
}
|
||||||
|
Action::RightHalf => {
|
||||||
|
let origin = CGPoint {
|
||||||
|
x: visible_frame.origin.x + visible_frame.size.width / 2.0,
|
||||||
|
y: visible_frame.origin.y,
|
||||||
|
};
|
||||||
|
let size = CGSize {
|
||||||
|
width: visible_frame.size.width / 2.0,
|
||||||
|
height: visible_frame.size.height,
|
||||||
|
};
|
||||||
|
let new_frame = CGRect { origin, size };
|
||||||
|
set_frontmost_window_frame(new_frame)
|
||||||
|
}
|
||||||
|
Action::CenterHalf => {
|
||||||
|
let origin = CGPoint {
|
||||||
|
x: visible_frame.origin.x + visible_frame.size.width / 4.0,
|
||||||
|
y: visible_frame.origin.y,
|
||||||
|
};
|
||||||
|
let size = CGSize {
|
||||||
|
width: visible_frame.size.width / 2.0,
|
||||||
|
height: visible_frame.size.height,
|
||||||
|
};
|
||||||
|
let new_frame = CGRect { origin, size };
|
||||||
|
set_frontmost_window_frame(new_frame)
|
||||||
|
}
|
||||||
|
Action::TopLeftQuarter => {
|
||||||
|
let origin = visible_frame.origin;
|
||||||
|
let size = CGSize {
|
||||||
|
width: visible_frame.size.width / 2.0,
|
||||||
|
height: visible_frame.size.height / 2.0,
|
||||||
|
};
|
||||||
|
let new_frame = CGRect { origin, size };
|
||||||
|
set_frontmost_window_frame(new_frame)
|
||||||
|
}
|
||||||
|
Action::TopRightQuarter => {
|
||||||
|
let origin = CGPoint {
|
||||||
|
x: visible_frame.origin.x + visible_frame.size.width / 2.0,
|
||||||
|
y: visible_frame.origin.y,
|
||||||
|
};
|
||||||
|
let size = CGSize {
|
||||||
|
width: visible_frame.size.width / 2.0,
|
||||||
|
height: visible_frame.size.height / 2.0,
|
||||||
|
};
|
||||||
|
let new_frame = CGRect { origin, size };
|
||||||
|
set_frontmost_window_frame(new_frame)
|
||||||
|
}
|
||||||
|
Action::BottomLeftQuarter => {
|
||||||
|
let origin = CGPoint {
|
||||||
|
x: visible_frame.origin.x,
|
||||||
|
y: visible_frame.origin.y + visible_frame.size.height / 2.0,
|
||||||
|
};
|
||||||
|
let size = CGSize {
|
||||||
|
width: visible_frame.size.width / 2.0,
|
||||||
|
height: visible_frame.size.height / 2.0,
|
||||||
|
};
|
||||||
|
let new_frame = CGRect { origin, size };
|
||||||
|
set_frontmost_window_frame(new_frame)
|
||||||
|
}
|
||||||
|
Action::BottomRightQuarter => {
|
||||||
|
let origin = CGPoint {
|
||||||
|
x: visible_frame.origin.x + visible_frame.size.width / 2.0,
|
||||||
|
y: visible_frame.origin.y + visible_frame.size.height / 2.0,
|
||||||
|
};
|
||||||
|
let size = CGSize {
|
||||||
|
width: visible_frame.size.width / 2.0,
|
||||||
|
height: visible_frame.size.height / 2.0,
|
||||||
|
};
|
||||||
|
let new_frame = CGRect { origin, size };
|
||||||
|
set_frontmost_window_frame(new_frame)
|
||||||
|
}
|
||||||
|
Action::TopLeftSixth => {
|
||||||
|
let origin = visible_frame.origin;
|
||||||
|
let size = CGSize {
|
||||||
|
width: visible_frame.size.width / 3.0,
|
||||||
|
height: visible_frame.size.height / 2.0,
|
||||||
|
};
|
||||||
|
let new_frame = CGRect { origin, size };
|
||||||
|
set_frontmost_window_frame(new_frame)
|
||||||
|
}
|
||||||
|
Action::TopCenterSixth => {
|
||||||
|
let origin = CGPoint {
|
||||||
|
x: visible_frame.origin.x + visible_frame.size.width / 3.0,
|
||||||
|
y: visible_frame.origin.y,
|
||||||
|
};
|
||||||
|
let size = CGSize {
|
||||||
|
width: visible_frame.size.width / 3.0,
|
||||||
|
height: visible_frame.size.height / 2.0,
|
||||||
|
};
|
||||||
|
let new_frame = CGRect { origin, size };
|
||||||
|
set_frontmost_window_frame(new_frame)
|
||||||
|
}
|
||||||
|
Action::TopRightSixth => {
|
||||||
|
let origin = CGPoint {
|
||||||
|
x: visible_frame.origin.x + visible_frame.size.width * 2.0 / 3.0,
|
||||||
|
y: visible_frame.origin.y,
|
||||||
|
};
|
||||||
|
let size = CGSize {
|
||||||
|
width: visible_frame.size.width / 3.0,
|
||||||
|
height: visible_frame.size.height / 2.0,
|
||||||
|
};
|
||||||
|
let new_frame = CGRect { origin, size };
|
||||||
|
set_frontmost_window_frame(new_frame)
|
||||||
|
}
|
||||||
|
Action::BottomLeftSixth => {
|
||||||
|
let origin = CGPoint {
|
||||||
|
x: visible_frame.origin.x,
|
||||||
|
y: visible_frame.origin.y + visible_frame.size.height / 2.0,
|
||||||
|
};
|
||||||
|
let size = CGSize {
|
||||||
|
width: visible_frame.size.width / 3.0,
|
||||||
|
height: visible_frame.size.height / 2.0,
|
||||||
|
};
|
||||||
|
let new_frame = CGRect { origin, size };
|
||||||
|
set_frontmost_window_frame(new_frame)
|
||||||
|
}
|
||||||
|
Action::BottomCenterSixth => {
|
||||||
|
let origin = CGPoint {
|
||||||
|
x: visible_frame.origin.x + visible_frame.size.width / 3.0,
|
||||||
|
y: visible_frame.origin.y + visible_frame.size.height / 2.0,
|
||||||
|
};
|
||||||
|
let size = CGSize {
|
||||||
|
width: visible_frame.size.width / 3.0,
|
||||||
|
height: visible_frame.size.height / 2.0,
|
||||||
|
};
|
||||||
|
let new_frame = CGRect { origin, size };
|
||||||
|
set_frontmost_window_frame(new_frame)
|
||||||
|
}
|
||||||
|
Action::BottomRightSixth => {
|
||||||
|
let origin = CGPoint {
|
||||||
|
x: visible_frame.origin.x + visible_frame.size.width * 2.0 / 3.0,
|
||||||
|
y: visible_frame.origin.y + visible_frame.size.height / 2.0,
|
||||||
|
};
|
||||||
|
let size = CGSize {
|
||||||
|
width: visible_frame.size.width / 3.0,
|
||||||
|
height: visible_frame.size.height / 2.0,
|
||||||
|
};
|
||||||
|
let new_frame = CGRect { origin, size };
|
||||||
|
set_frontmost_window_frame(new_frame)
|
||||||
|
}
|
||||||
|
Action::TopThird => {
|
||||||
|
let origin = visible_frame.origin;
|
||||||
|
let size = CGSize {
|
||||||
|
width: visible_frame.size.width,
|
||||||
|
height: visible_frame.size.height / 3.0,
|
||||||
|
};
|
||||||
|
let new_frame = CGRect { origin, size };
|
||||||
|
set_frontmost_window_frame(new_frame)
|
||||||
|
}
|
||||||
|
Action::MiddleThird => {
|
||||||
|
let origin = CGPoint {
|
||||||
|
x: visible_frame.origin.x,
|
||||||
|
y: visible_frame.origin.y + visible_frame.size.height / 3.0,
|
||||||
|
};
|
||||||
|
let size = CGSize {
|
||||||
|
width: visible_frame.size.width,
|
||||||
|
height: visible_frame.size.height / 3.0,
|
||||||
|
};
|
||||||
|
let new_frame = CGRect { origin, size };
|
||||||
|
set_frontmost_window_frame(new_frame)
|
||||||
|
}
|
||||||
|
Action::BottomThird => {
|
||||||
|
let origin = CGPoint {
|
||||||
|
x: visible_frame.origin.x,
|
||||||
|
y: visible_frame.origin.y + visible_frame.size.height * 2.0 / 3.0,
|
||||||
|
};
|
||||||
|
let size = CGSize {
|
||||||
|
width: visible_frame.size.width,
|
||||||
|
height: visible_frame.size.height / 3.0,
|
||||||
|
};
|
||||||
|
let new_frame = CGRect { origin, size };
|
||||||
|
set_frontmost_window_frame(new_frame)
|
||||||
|
}
|
||||||
|
Action::Center => {
|
||||||
|
let window_size = frontmost_window_frame.size;
|
||||||
|
let origin = CGPoint {
|
||||||
|
x: visible_frame.origin.x + (visible_frame.size.width - window_size.width) / 2.0,
|
||||||
|
y: visible_frame.origin.y + (visible_frame.size.height - window_size.height) / 2.0,
|
||||||
|
};
|
||||||
|
move_frontmost_window(origin.x, origin.y)
|
||||||
|
}
|
||||||
|
Action::FirstFourth => {
|
||||||
|
let origin = visible_frame.origin;
|
||||||
|
let size = CGSize {
|
||||||
|
width: visible_frame.size.width / 4.0,
|
||||||
|
height: visible_frame.size.height,
|
||||||
|
};
|
||||||
|
let new_frame = CGRect { origin, size };
|
||||||
|
set_frontmost_window_frame(new_frame)
|
||||||
|
}
|
||||||
|
Action::SecondFourth => {
|
||||||
|
let origin = CGPoint {
|
||||||
|
x: visible_frame.origin.x + visible_frame.size.width / 4.0,
|
||||||
|
y: visible_frame.origin.y,
|
||||||
|
};
|
||||||
|
let size = CGSize {
|
||||||
|
width: visible_frame.size.width / 4.0,
|
||||||
|
height: visible_frame.size.height,
|
||||||
|
};
|
||||||
|
let new_frame = CGRect { origin, size };
|
||||||
|
set_frontmost_window_frame(new_frame)
|
||||||
|
}
|
||||||
|
Action::ThirdFourth => {
|
||||||
|
let origin = CGPoint {
|
||||||
|
x: visible_frame.origin.x + visible_frame.size.width * 2.0 / 4.0,
|
||||||
|
y: visible_frame.origin.y,
|
||||||
|
};
|
||||||
|
let size = CGSize {
|
||||||
|
width: visible_frame.size.width / 4.0,
|
||||||
|
height: visible_frame.size.height,
|
||||||
|
};
|
||||||
|
let new_frame = CGRect { origin, size };
|
||||||
|
set_frontmost_window_frame(new_frame)
|
||||||
|
}
|
||||||
|
Action::LastFourth => {
|
||||||
|
let origin = CGPoint {
|
||||||
|
x: visible_frame.origin.x + visible_frame.size.width * 3.0 / 4.0,
|
||||||
|
y: visible_frame.origin.y,
|
||||||
|
};
|
||||||
|
let size = CGSize {
|
||||||
|
width: visible_frame.size.width / 4.0,
|
||||||
|
height: visible_frame.size.height,
|
||||||
|
};
|
||||||
|
let new_frame = CGRect { origin, size };
|
||||||
|
set_frontmost_window_frame(new_frame)
|
||||||
|
}
|
||||||
|
Action::FirstThird => {
|
||||||
|
let origin = CGPoint {
|
||||||
|
x: visible_frame.origin.x,
|
||||||
|
y: visible_frame.origin.y,
|
||||||
|
};
|
||||||
|
let size = CGSize {
|
||||||
|
width: visible_frame.size.width / 3.0,
|
||||||
|
height: visible_frame.size.height,
|
||||||
|
};
|
||||||
|
let new_frame = CGRect { origin, size };
|
||||||
|
set_frontmost_window_frame(new_frame)
|
||||||
|
}
|
||||||
|
Action::CenterThird => {
|
||||||
|
let origin = CGPoint {
|
||||||
|
x: visible_frame.origin.x + visible_frame.size.width / 3.0,
|
||||||
|
y: visible_frame.origin.y,
|
||||||
|
};
|
||||||
|
let size = CGSize {
|
||||||
|
width: visible_frame.size.width / 3.0,
|
||||||
|
height: visible_frame.size.height,
|
||||||
|
};
|
||||||
|
let new_frame = CGRect { origin, size };
|
||||||
|
set_frontmost_window_frame(new_frame)
|
||||||
|
}
|
||||||
|
Action::LastThird => {
|
||||||
|
let origin = CGPoint {
|
||||||
|
x: visible_frame.origin.x + visible_frame.size.width * 2.0 / 3.0,
|
||||||
|
y: visible_frame.origin.y,
|
||||||
|
};
|
||||||
|
let size = CGSize {
|
||||||
|
width: visible_frame.size.width / 3.0,
|
||||||
|
height: visible_frame.size.height,
|
||||||
|
};
|
||||||
|
let new_frame = CGRect { origin, size };
|
||||||
|
set_frontmost_window_frame(new_frame)
|
||||||
|
}
|
||||||
|
Action::FirstTwoThirds => {
|
||||||
|
let origin = CGPoint {
|
||||||
|
x: visible_frame.origin.x,
|
||||||
|
y: visible_frame.origin.y,
|
||||||
|
};
|
||||||
|
let size = CGSize {
|
||||||
|
width: visible_frame.size.width * 2.0 / 3.0,
|
||||||
|
height: visible_frame.size.height,
|
||||||
|
};
|
||||||
|
let new_frame = CGRect { origin, size };
|
||||||
|
set_frontmost_window_frame(new_frame)
|
||||||
|
}
|
||||||
|
Action::CenterTwoThirds => {
|
||||||
|
let origin = CGPoint {
|
||||||
|
x: visible_frame.origin.x + visible_frame.size.width / 6.0,
|
||||||
|
y: visible_frame.origin.y,
|
||||||
|
};
|
||||||
|
let size = CGSize {
|
||||||
|
width: visible_frame.size.width * 2.0 / 3.0,
|
||||||
|
height: visible_frame.size.height,
|
||||||
|
};
|
||||||
|
let new_frame = CGRect { origin, size };
|
||||||
|
set_frontmost_window_frame(new_frame)
|
||||||
|
}
|
||||||
|
Action::LastTwoThirds => {
|
||||||
|
let origin = CGPoint {
|
||||||
|
x: visible_frame.origin.x + visible_frame.size.width / 3.0,
|
||||||
|
y: visible_frame.origin.y,
|
||||||
|
};
|
||||||
|
let size = CGSize {
|
||||||
|
width: visible_frame.size.width * 2.0 / 3.0,
|
||||||
|
height: visible_frame.size.height,
|
||||||
|
};
|
||||||
|
let new_frame = CGRect { origin, size };
|
||||||
|
set_frontmost_window_frame(new_frame)
|
||||||
|
}
|
||||||
|
Action::FirstThreeFourths => {
|
||||||
|
let origin = CGPoint {
|
||||||
|
x: visible_frame.origin.x,
|
||||||
|
y: visible_frame.origin.y,
|
||||||
|
};
|
||||||
|
let size = CGSize {
|
||||||
|
width: visible_frame.size.width * 3.0 / 4.0,
|
||||||
|
height: visible_frame.size.height,
|
||||||
|
};
|
||||||
|
let new_frame = CGRect { origin, size };
|
||||||
|
set_frontmost_window_frame(new_frame)
|
||||||
|
}
|
||||||
|
Action::CenterThreeFourths => {
|
||||||
|
let origin = CGPoint {
|
||||||
|
x: visible_frame.origin.x + visible_frame.size.width / 8.0,
|
||||||
|
y: visible_frame.origin.y,
|
||||||
|
};
|
||||||
|
let size = CGSize {
|
||||||
|
width: visible_frame.size.width * 3.0 / 4.0,
|
||||||
|
height: visible_frame.size.height,
|
||||||
|
};
|
||||||
|
let new_frame = CGRect { origin, size };
|
||||||
|
set_frontmost_window_frame(new_frame)
|
||||||
|
}
|
||||||
|
Action::LastThreeFourths => {
|
||||||
|
let origin = CGPoint {
|
||||||
|
x: visible_frame.origin.x + visible_frame.size.width / 4.0,
|
||||||
|
y: visible_frame.origin.y,
|
||||||
|
};
|
||||||
|
let size = CGSize {
|
||||||
|
width: visible_frame.size.width * 3.0 / 4.0,
|
||||||
|
height: visible_frame.size.height,
|
||||||
|
};
|
||||||
|
let new_frame = CGRect { origin, size };
|
||||||
|
set_frontmost_window_frame(new_frame)
|
||||||
|
}
|
||||||
|
Action::TopThreeFourths => {
|
||||||
|
let origin = CGPoint {
|
||||||
|
x: visible_frame.origin.x,
|
||||||
|
y: visible_frame.origin.y,
|
||||||
|
};
|
||||||
|
let size = CGSize {
|
||||||
|
width: visible_frame.size.width,
|
||||||
|
height: visible_frame.size.height * 3.0 / 4.0,
|
||||||
|
};
|
||||||
|
let new_frame = CGRect { origin, size };
|
||||||
|
set_frontmost_window_frame(new_frame)
|
||||||
|
}
|
||||||
|
Action::BottomThreeFourths => {
|
||||||
|
let origin = CGPoint {
|
||||||
|
x: visible_frame.origin.x,
|
||||||
|
y: visible_frame.origin.y + visible_frame.size.height / 4.0,
|
||||||
|
};
|
||||||
|
let size = CGSize {
|
||||||
|
width: visible_frame.size.width,
|
||||||
|
height: visible_frame.size.height * 3.0 / 4.0,
|
||||||
|
};
|
||||||
|
let new_frame = CGRect { origin, size };
|
||||||
|
set_frontmost_window_frame(new_frame)
|
||||||
|
}
|
||||||
|
Action::TopTwoThirds => {
|
||||||
|
let origin = CGPoint {
|
||||||
|
x: visible_frame.origin.x,
|
||||||
|
y: visible_frame.origin.y,
|
||||||
|
};
|
||||||
|
let size = CGSize {
|
||||||
|
width: visible_frame.size.width,
|
||||||
|
height: visible_frame.size.height * 2.0 / 3.0,
|
||||||
|
};
|
||||||
|
let new_frame = CGRect { origin, size };
|
||||||
|
set_frontmost_window_frame(new_frame)
|
||||||
|
}
|
||||||
|
Action::BottomTwoThirds => {
|
||||||
|
let origin = CGPoint {
|
||||||
|
x: visible_frame.origin.x,
|
||||||
|
y: visible_frame.origin.y + visible_frame.size.height / 3.0,
|
||||||
|
};
|
||||||
|
let size = CGSize {
|
||||||
|
width: visible_frame.size.width,
|
||||||
|
height: visible_frame.size.height * 2.0 / 3.0,
|
||||||
|
};
|
||||||
|
let new_frame = CGRect { origin, size };
|
||||||
|
set_frontmost_window_frame(new_frame)
|
||||||
|
}
|
||||||
|
|
||||||
|
Action::TopCenterTwoThirds => {
|
||||||
|
let origin = CGPoint {
|
||||||
|
x: visible_frame.origin.x + visible_frame.size.width / 6.0,
|
||||||
|
y: visible_frame.origin.y,
|
||||||
|
};
|
||||||
|
let size = CGSize {
|
||||||
|
width: visible_frame.size.width * 2.0 / 3.0,
|
||||||
|
height: visible_frame.size.height * 2.0 / 3.0,
|
||||||
|
};
|
||||||
|
let new_frame = CGRect { origin, size };
|
||||||
|
set_frontmost_window_frame(new_frame)
|
||||||
|
}
|
||||||
|
Action::TopFirstFourth => {
|
||||||
|
let origin = CGPoint {
|
||||||
|
x: visible_frame.origin.x,
|
||||||
|
y: visible_frame.origin.y,
|
||||||
|
};
|
||||||
|
let size = CGSize {
|
||||||
|
width: visible_frame.size.width,
|
||||||
|
height: visible_frame.size.height / 4.0,
|
||||||
|
};
|
||||||
|
let new_frame = CGRect { origin, size };
|
||||||
|
set_frontmost_window_frame(new_frame)
|
||||||
|
}
|
||||||
|
Action::TopSecondFourth => {
|
||||||
|
let origin = CGPoint {
|
||||||
|
x: visible_frame.origin.x,
|
||||||
|
y: visible_frame.origin.y + visible_frame.size.height / 4.0,
|
||||||
|
};
|
||||||
|
let size = CGSize {
|
||||||
|
width: visible_frame.size.width,
|
||||||
|
height: visible_frame.size.height / 4.0,
|
||||||
|
};
|
||||||
|
let new_frame = CGRect { origin, size };
|
||||||
|
set_frontmost_window_frame(new_frame)
|
||||||
|
}
|
||||||
|
Action::TopThirdFourth => {
|
||||||
|
let origin = CGPoint {
|
||||||
|
x: visible_frame.origin.x,
|
||||||
|
y: visible_frame.origin.y + visible_frame.size.height * 2.0 / 4.0,
|
||||||
|
};
|
||||||
|
let size = CGSize {
|
||||||
|
width: visible_frame.size.width,
|
||||||
|
height: visible_frame.size.height / 4.0,
|
||||||
|
};
|
||||||
|
let new_frame = CGRect { origin, size };
|
||||||
|
set_frontmost_window_frame(new_frame)
|
||||||
|
}
|
||||||
|
Action::TopLastFourth => {
|
||||||
|
let origin = CGPoint {
|
||||||
|
x: visible_frame.origin.x,
|
||||||
|
y: visible_frame.origin.y + visible_frame.size.height * 3.0 / 4.0,
|
||||||
|
};
|
||||||
|
let size = CGSize {
|
||||||
|
width: visible_frame.size.width,
|
||||||
|
height: visible_frame.size.height / 4.0,
|
||||||
|
};
|
||||||
|
let new_frame = CGRect { origin, size };
|
||||||
|
set_frontmost_window_frame(new_frame)
|
||||||
|
}
|
||||||
|
Action::MakeLarger => {
|
||||||
|
let window_origin = frontmost_window_frame.origin;
|
||||||
|
let window_size = frontmost_window_frame.size;
|
||||||
|
let delta_width = 20_f64;
|
||||||
|
let delta_height = window_size.height / window_size.width * delta_width;
|
||||||
|
let delta_origin_x = delta_width / 2.0;
|
||||||
|
let delta_origin_y = delta_height / 2.0;
|
||||||
|
|
||||||
|
let new_width = {
|
||||||
|
let possible_value = window_size.width + delta_width;
|
||||||
|
if possible_value > visible_frame.size.width {
|
||||||
|
visible_frame.size.width
|
||||||
|
} else {
|
||||||
|
possible_value
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let new_height = {
|
||||||
|
let possible_value = window_size.height + delta_height;
|
||||||
|
if possible_value > visible_frame.size.height {
|
||||||
|
visible_frame.size.height
|
||||||
|
} else {
|
||||||
|
possible_value
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let new_origin_x = {
|
||||||
|
let possible_value = window_origin.x - delta_origin_x;
|
||||||
|
if possible_value < visible_frame.origin.x {
|
||||||
|
visible_frame.origin.x
|
||||||
|
} else {
|
||||||
|
possible_value
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let new_origin_y = {
|
||||||
|
let possible_value = window_origin.y - delta_origin_y;
|
||||||
|
if possible_value < visible_frame.origin.y {
|
||||||
|
visible_frame.origin.y
|
||||||
|
} else {
|
||||||
|
possible_value
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let origin = CGPoint {
|
||||||
|
x: new_origin_x,
|
||||||
|
y: new_origin_y,
|
||||||
|
};
|
||||||
|
let size = CGSize {
|
||||||
|
width: new_width,
|
||||||
|
height: new_height,
|
||||||
|
};
|
||||||
|
let new_frame = CGRect { origin, size };
|
||||||
|
set_frontmost_window_frame(new_frame)
|
||||||
|
}
|
||||||
|
Action::MakeSmaller => {
|
||||||
|
let window_origin = frontmost_window_frame.origin;
|
||||||
|
let window_size = frontmost_window_frame.size;
|
||||||
|
|
||||||
|
let delta_width = 20_f64;
|
||||||
|
let delta_height = window_size.height / window_size.width * delta_width;
|
||||||
|
|
||||||
|
let delta_origin_x = delta_width / 2.0;
|
||||||
|
let delta_origin_y = delta_height / 2.0;
|
||||||
|
|
||||||
|
let origin = CGPoint {
|
||||||
|
x: window_origin.x + delta_origin_x,
|
||||||
|
y: window_origin.y + delta_origin_y,
|
||||||
|
};
|
||||||
|
let size = CGSize {
|
||||||
|
width: window_size.width - delta_width,
|
||||||
|
height: window_size.height - delta_height,
|
||||||
|
};
|
||||||
|
let new_frame = CGRect { origin, size };
|
||||||
|
set_frontmost_window_frame(new_frame)
|
||||||
|
}
|
||||||
|
Action::AlmostMaximize => {
|
||||||
|
let new_size = CGSize {
|
||||||
|
width: visible_frame.size.width * 0.9,
|
||||||
|
height: visible_frame.size.height * 0.9,
|
||||||
|
};
|
||||||
|
let new_origin = CGPoint {
|
||||||
|
x: visible_frame.origin.x + (visible_frame.size.width * 0.1),
|
||||||
|
y: visible_frame.origin.y + (visible_frame.size.height * 0.1),
|
||||||
|
};
|
||||||
|
let new_frame = CGRect {
|
||||||
|
origin: new_origin,
|
||||||
|
size: new_size,
|
||||||
|
};
|
||||||
|
set_frontmost_window_frame(new_frame)
|
||||||
|
}
|
||||||
|
Action::Maximize => {
|
||||||
|
let new_frame = CGRect {
|
||||||
|
origin: visible_frame.origin,
|
||||||
|
size: visible_frame.size,
|
||||||
|
};
|
||||||
|
set_frontmost_window_frame(new_frame)
|
||||||
|
}
|
||||||
|
Action::MaximizeWidth => {
|
||||||
|
let window_origin = frontmost_window_frame.origin;
|
||||||
|
let window_size = frontmost_window_frame.size;
|
||||||
|
let origin = CGPoint {
|
||||||
|
x: visible_frame.origin.x,
|
||||||
|
y: window_origin.y,
|
||||||
|
};
|
||||||
|
let size = CGSize {
|
||||||
|
width: visible_frame.size.width,
|
||||||
|
height: window_size.height,
|
||||||
|
};
|
||||||
|
|
||||||
|
let new_frame = CGRect { origin, size };
|
||||||
|
set_frontmost_window_frame(new_frame)
|
||||||
|
}
|
||||||
|
Action::MaximizeHeight => {
|
||||||
|
let window_origin = frontmost_window_frame.origin;
|
||||||
|
let window_size = frontmost_window_frame.size;
|
||||||
|
let origin = CGPoint {
|
||||||
|
x: window_origin.x,
|
||||||
|
y: visible_frame.origin.y,
|
||||||
|
};
|
||||||
|
let size = CGSize {
|
||||||
|
width: window_size.width,
|
||||||
|
height: visible_frame.size.height,
|
||||||
|
};
|
||||||
|
|
||||||
|
let new_frame = CGRect { origin, size };
|
||||||
|
set_frontmost_window_frame(new_frame)
|
||||||
|
}
|
||||||
|
Action::MoveUp => {
|
||||||
|
let window_origin = frontmost_window_frame.origin;
|
||||||
|
let new_y = (window_origin.y - 10.0).max(visible_frame.origin.y);
|
||||||
|
move_frontmost_window(window_origin.x, new_y)
|
||||||
|
}
|
||||||
|
Action::MoveDown => {
|
||||||
|
let window_origin = frontmost_window_frame.origin;
|
||||||
|
let window_size = frontmost_window_frame.size;
|
||||||
|
let new_y = (window_origin.y + 10.0)
|
||||||
|
.min(visible_frame.origin.y + visible_frame.size.height - window_size.height);
|
||||||
|
move_frontmost_window(window_origin.x, new_y)
|
||||||
|
}
|
||||||
|
Action::MoveLeft => {
|
||||||
|
let window_origin = frontmost_window_frame.origin;
|
||||||
|
let new_x = (window_origin.x - 10.0).max(visible_frame.origin.x);
|
||||||
|
move_frontmost_window(new_x, window_origin.y)
|
||||||
|
}
|
||||||
|
Action::MoveRight => {
|
||||||
|
let window_origin = frontmost_window_frame.origin;
|
||||||
|
let window_size = frontmost_window_frame.size;
|
||||||
|
let new_x = (window_origin.x + 10.0)
|
||||||
|
.min(visible_frame.origin.x + visible_frame.size.width - window_size.width);
|
||||||
|
move_frontmost_window(new_x, window_origin.y)
|
||||||
|
}
|
||||||
|
Action::NextDesktop => {
|
||||||
|
let Some(next_workspace_logical_id) = get_next_workspace_logical_id() else {
|
||||||
|
// nothing to do
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
move_frontmost_window_to_workspace(next_workspace_logical_id)
|
||||||
|
}
|
||||||
|
Action::PreviousDesktop => {
|
||||||
|
let Some(previous_workspace_logical_id) = get_previous_workspace_logical_id() else {
|
||||||
|
// nothing to do
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
// Now let's switch the workspace
|
||||||
|
move_frontmost_window_to_workspace(previous_workspace_logical_id)
|
||||||
|
}
|
||||||
|
Action::NextDisplay => {
|
||||||
|
const TOO_MANY_MONITORS: &str = "I don't think you can have so many monitors";
|
||||||
|
|
||||||
|
let frames = list_visible_frame_of_all_screens()?;
|
||||||
|
let n_frames = frames.len();
|
||||||
|
if n_frames == 0 {
|
||||||
|
return Err(Error::NoDisplay);
|
||||||
|
}
|
||||||
|
if n_frames == 1 {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let index = frames
|
||||||
|
.iter()
|
||||||
|
.position(|fr| fr == &visible_frame)
|
||||||
|
.expect("active screen should be in the list");
|
||||||
|
let new_index: usize = {
|
||||||
|
let index_i32: i32 = index.try_into().expect(TOO_MANY_MONITORS);
|
||||||
|
let index_i32_plus_one = index_i32.checked_add(1).expect(TOO_MANY_MONITORS);
|
||||||
|
let final_value = index_i32_plus_one % n_frames as i32;
|
||||||
|
|
||||||
|
final_value
|
||||||
|
.try_into()
|
||||||
|
.expect("final value should be positive")
|
||||||
|
};
|
||||||
|
|
||||||
|
let new_frame = frames[new_index];
|
||||||
|
|
||||||
|
set_frontmost_window_frame(new_frame)
|
||||||
|
}
|
||||||
|
Action::PreviousDisplay => {
|
||||||
|
const TOO_MANY_MONITORS: &str = "I don't think you can have so many monitors";
|
||||||
|
|
||||||
|
let frames = list_visible_frame_of_all_screens()?;
|
||||||
|
let n_frames = frames.len();
|
||||||
|
if n_frames == 0 {
|
||||||
|
return Err(Error::NoDisplay);
|
||||||
|
}
|
||||||
|
if n_frames == 1 {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let index = frames
|
||||||
|
.iter()
|
||||||
|
.position(|fr| fr == &visible_frame)
|
||||||
|
.expect("active screen should be in the list");
|
||||||
|
let new_index: usize = {
|
||||||
|
let index_i32: i32 = index.try_into().expect(TOO_MANY_MONITORS);
|
||||||
|
let index_i32_minus_one = index_i32 - 1;
|
||||||
|
let n_frames_i32: i32 = n_frames.try_into().expect(TOO_MANY_MONITORS);
|
||||||
|
let final_value = (index_i32_minus_one + n_frames_i32) % n_frames_i32;
|
||||||
|
|
||||||
|
final_value
|
||||||
|
.try_into()
|
||||||
|
.expect("final value should be positive")
|
||||||
|
};
|
||||||
|
|
||||||
|
let new_frame = frames[new_index];
|
||||||
|
|
||||||
|
set_frontmost_window_frame(new_frame)
|
||||||
|
}
|
||||||
|
Action::Restore => {
|
||||||
|
let Some(previous_frame) = get_frontmost_window_last_frame(frontmost_window_id) else {
|
||||||
|
// Previous frame found, Nothing to do
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
set_frontmost_window_frame(previous_frame)
|
||||||
|
}
|
||||||
|
Action::ToggleFullscreen => toggle_fullscreen(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn set_up_commands_hotkeys(
|
||||||
|
tauri_app_handle: &AppHandle,
|
||||||
|
wm_extension: &Extension,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
for command in wm_extension
|
||||||
|
.commands
|
||||||
|
.as_ref()
|
||||||
|
.expect("Window Management extension has commands")
|
||||||
|
.iter()
|
||||||
|
.filter(|cmd| cmd.enabled)
|
||||||
|
{
|
||||||
|
if let Some(ref hotkey) = command.hotkey {
|
||||||
|
let on_opened = on_opened::on_opened(&command.id);
|
||||||
|
|
||||||
|
let extension_id_clone = command.id.clone();
|
||||||
|
|
||||||
|
tauri_app_handle
|
||||||
|
.global_shortcut()
|
||||||
|
.on_shortcut(hotkey.as_str(), move |tauri_app_handle, _hotkey, event| {
|
||||||
|
let on_opened_clone = on_opened.clone();
|
||||||
|
let extension_id_clone = extension_id_clone.clone();
|
||||||
|
let app_handle_clone = tauri_app_handle.clone();
|
||||||
|
|
||||||
|
if event.state() == ShortcutState::Pressed {
|
||||||
|
async_runtime::spawn(async move {
|
||||||
|
let result = open(app_handle_clone, on_opened_clone, None).await;
|
||||||
|
if let Err(msg) = result {
|
||||||
|
log::warn!(
|
||||||
|
"failed to open extension [{}], error [{}]",
|
||||||
|
extension_id_clone,
|
||||||
|
msg
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn unset_commands_hotkeys(
|
||||||
|
tauri_app_handle: &AppHandle,
|
||||||
|
wm_extension: &Extension,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
for command in wm_extension
|
||||||
|
.commands
|
||||||
|
.as_ref()
|
||||||
|
.expect("Window Management extension has commands")
|
||||||
|
.iter()
|
||||||
|
.filter(|cmd| cmd.enabled)
|
||||||
|
{
|
||||||
|
if let Some(ref hotkey) = command.hotkey {
|
||||||
|
tauri_app_handle
|
||||||
|
.global_shortcut()
|
||||||
|
.unregister(hotkey.as_str())
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn set_up_command_hotkey(
|
||||||
|
tauri_app_handle: &AppHandle,
|
||||||
|
wm_extension: &Extension,
|
||||||
|
command_id: &str,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let commands = wm_extension
|
||||||
|
.commands
|
||||||
|
.as_ref()
|
||||||
|
.expect("Window Management has commands");
|
||||||
|
let opt_command = commands.iter().find(|ext| ext.id == command_id);
|
||||||
|
|
||||||
|
let Some(command) = opt_command else {
|
||||||
|
panic!("Window Management command does not exist {}", command_id);
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(ref hotkey) = command.hotkey {
|
||||||
|
let on_opened = on_opened::on_opened(&command.id);
|
||||||
|
|
||||||
|
let extension_id_clone = command.id.clone();
|
||||||
|
|
||||||
|
tauri_app_handle
|
||||||
|
.global_shortcut()
|
||||||
|
.on_shortcut(hotkey.as_str(), move |tauri_app_handle, _hotkey, event| {
|
||||||
|
let on_opened_clone = on_opened.clone();
|
||||||
|
let extension_id_clone = extension_id_clone.clone();
|
||||||
|
let app_handle_clone = tauri_app_handle.clone();
|
||||||
|
|
||||||
|
if event.state() == ShortcutState::Pressed {
|
||||||
|
async_runtime::spawn(async move {
|
||||||
|
let result = open(app_handle_clone, on_opened_clone, None).await;
|
||||||
|
if let Err(msg) = result {
|
||||||
|
log::warn!(
|
||||||
|
"failed to open extension [{}], error [{}]",
|
||||||
|
extension_id_clone,
|
||||||
|
msg
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn unset_command_hotkey(
|
||||||
|
tauri_app_handle: &AppHandle,
|
||||||
|
wm_extension: &Extension,
|
||||||
|
command_id: &str,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let commands = wm_extension
|
||||||
|
.commands
|
||||||
|
.as_ref()
|
||||||
|
.expect("Window Management has commands");
|
||||||
|
let opt_command = commands.iter().find(|ext| ext.id == command_id);
|
||||||
|
|
||||||
|
let Some(command) = opt_command else {
|
||||||
|
panic!("Window Management command does not exist {}", command_id);
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(ref hotkey) = command.hotkey {
|
||||||
|
tauri_app_handle
|
||||||
|
.global_shortcut()
|
||||||
|
.unregister(hotkey.as_str())
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn register_command_hotkey(
|
||||||
|
tauri_app_handle: &AppHandle,
|
||||||
|
command_id: &str,
|
||||||
|
hotkey: &str,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let on_opened = on_opened::on_opened(&command_id);
|
||||||
|
|
||||||
|
let extension_id_clone = command_id.to_string();
|
||||||
|
|
||||||
|
tauri_app_handle
|
||||||
|
.global_shortcut()
|
||||||
|
.on_shortcut(hotkey, move |tauri_app_handle, _hotkey, event| {
|
||||||
|
let on_opened_clone = on_opened.clone();
|
||||||
|
let extension_id_clone = extension_id_clone.clone();
|
||||||
|
let app_handle_clone = tauri_app_handle.clone();
|
||||||
|
|
||||||
|
if event.state() == ShortcutState::Pressed {
|
||||||
|
async_runtime::spawn(async move {
|
||||||
|
let result = open(app_handle_clone, on_opened_clone, None).await;
|
||||||
|
if let Err(msg) = result {
|
||||||
|
log::warn!(
|
||||||
|
"failed to open extension [{}], error [{}]",
|
||||||
|
extension_id_clone,
|
||||||
|
msg
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn unregister_command_hotkey(
|
||||||
|
tauri_app_handle: &AppHandle,
|
||||||
|
wm_extension: &Extension,
|
||||||
|
command_id: &str,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let commands = wm_extension
|
||||||
|
.commands
|
||||||
|
.as_ref()
|
||||||
|
.expect("Window Management has commands");
|
||||||
|
let opt_command = commands.iter().find(|ext| ext.id == command_id);
|
||||||
|
|
||||||
|
let Some(command) = opt_command else {
|
||||||
|
panic!("Window Management command does not exist {}", command_id);
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(ref hotkey) = command.hotkey else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
tauri_app_handle
|
||||||
|
.global_shortcut()
|
||||||
|
.unregister(hotkey.as_str())
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
use super::actions::Action;
|
||||||
|
use crate::common::document::OnOpened;
|
||||||
|
use serde_plain;
|
||||||
|
|
||||||
|
pub(crate) fn on_opened(command_id: &str) -> OnOpened {
|
||||||
|
let action: Action = serde_plain::from_str(command_id).unwrap_or_else(|_| {
|
||||||
|
panic!("Window Management commands IDs should be valid for `enum Action`, someone corrupts the JSON file");
|
||||||
|
});
|
||||||
|
OnOpened::WindowManagementAction { action }
|
||||||
|
}
|
||||||
415
src-tauri/src/extension/built_in/window_management/plugin.json
Normal file
415
src-tauri/src/extension/built_in/window_management/plugin.json
Normal file
@@ -0,0 +1,415 @@
|
|||||||
|
{
|
||||||
|
"id": "Window Management",
|
||||||
|
"name": "Window Management",
|
||||||
|
"platforms": [
|
||||||
|
"macos"
|
||||||
|
],
|
||||||
|
"description": "Resize, reorganize and move your focused window effortlessly",
|
||||||
|
"icon": "font_a-Windowmanagement",
|
||||||
|
"type": "extension",
|
||||||
|
"category": "Utilities",
|
||||||
|
"tags": [
|
||||||
|
"Productivity"
|
||||||
|
],
|
||||||
|
"commands": [
|
||||||
|
{
|
||||||
|
"id": "TopHalf",
|
||||||
|
"name": "Top Half",
|
||||||
|
"description": "Move the focused window to fill left half of the screen.",
|
||||||
|
"icon": "font_a-TopHalf",
|
||||||
|
"type": "command"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "BottomHalf",
|
||||||
|
"name": "Bottom Half",
|
||||||
|
"description": "Move the focused window to fill bottom half of the screen.",
|
||||||
|
"icon": "font_a-BottomHalf",
|
||||||
|
"type": "command"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "LeftHalf",
|
||||||
|
"name": "Left Half",
|
||||||
|
"description": "Move the focused window to fill left half of the screen.",
|
||||||
|
"icon": "font_a-LeftHalf",
|
||||||
|
"type": "command"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "RightHalf",
|
||||||
|
"name": "Right Half",
|
||||||
|
"description": "Move the focused window to fill right half of the screen.",
|
||||||
|
"icon": "font_a-RightHalf",
|
||||||
|
"type": "command"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "CenterHalf",
|
||||||
|
"name": "Center Half",
|
||||||
|
"description": "Move the focused window to fill center half of the screen.",
|
||||||
|
"icon": "font_a-CenterHalf",
|
||||||
|
"type": "command"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "Maximize",
|
||||||
|
"name": "Maximize",
|
||||||
|
"description": "Maximize the focused window to fit the screen.",
|
||||||
|
"icon": "font_Maximize",
|
||||||
|
"type": "command"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "TopLeftQuarter",
|
||||||
|
"name": "Top Left Quarter",
|
||||||
|
"description": "Resize the focused window to the top left quarter of the screen.",
|
||||||
|
"icon": "font_a-TopLeftQuarter",
|
||||||
|
"type": "command"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "TopRightQuarter",
|
||||||
|
"name": "Top Right Quarter",
|
||||||
|
"description": "Resize the focused window to the top right quarter of the screen.",
|
||||||
|
"icon": "font_a-TopRightQuarter",
|
||||||
|
"type": "command"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "BottomLeftQuarter",
|
||||||
|
"name": "Bottom Left Quarter",
|
||||||
|
"description": "Resize the focused window to the bottom left quarter of the screen.",
|
||||||
|
"icon": "font_a-BottomLeftQuarter",
|
||||||
|
"type": "command"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "BottomRightQuarter",
|
||||||
|
"name": "Bottom Right Quarter",
|
||||||
|
"description": "Resize the focused window to the bottom right quarter of the screen.",
|
||||||
|
"icon": "font_a-BottomRightQuarter",
|
||||||
|
"type": "command"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "TopLeftSixth",
|
||||||
|
"name": "Top Left Sixth",
|
||||||
|
"description": "Resize the focused window to the top left sixth of the screen.",
|
||||||
|
"icon": "font_a-TopLeftSixth",
|
||||||
|
"type": "command"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "TopCenterSixth",
|
||||||
|
"name": "Top Center Sixth",
|
||||||
|
"description": "Resize the focused window to the top center sixth of the screen.",
|
||||||
|
"icon": "font_a-TopCenterSixth",
|
||||||
|
"type": "command"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "TopRightSixth",
|
||||||
|
"name": "Top Right Sixth",
|
||||||
|
"description": "Resize the focused window to the top right sixth of the screen.",
|
||||||
|
"icon": "font_a-TopRightSixth",
|
||||||
|
"type": "command"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "BottomLeftSixth",
|
||||||
|
"name": "Bottom Left Sixth",
|
||||||
|
"description": "Resize the focused window to the bottom left sixth of the screen.",
|
||||||
|
"icon": "font_a-BottomLeftSixth",
|
||||||
|
"type": "command"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "BottomCenterSixth",
|
||||||
|
"name": "Bottom Center Sixth",
|
||||||
|
"description": "Resize the focused window to the bottom center sixth of the screen.",
|
||||||
|
"icon": "font_a-BottomCenterSixth",
|
||||||
|
"type": "command"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "BottomRightSixth",
|
||||||
|
"name": "Bottom Right Sixth",
|
||||||
|
"description": "Resize the focused window to the bottom right sixth of the screen.",
|
||||||
|
"icon": "font_a-BottomRightSixth",
|
||||||
|
"type": "command"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "TopThird",
|
||||||
|
"name": "Top Third",
|
||||||
|
"description": "Resize the focused window to the top third of the screen.",
|
||||||
|
"icon": "font_a-TopThirdFourth",
|
||||||
|
"type": "command"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "MiddleThird",
|
||||||
|
"name": "Middle Third",
|
||||||
|
"description": "Resize the focused window to the middle third of the screen.",
|
||||||
|
"icon": "font_a-MiddleThird",
|
||||||
|
"type": "command"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "BottomThird",
|
||||||
|
"name": "Bottom Third",
|
||||||
|
"description": "Resize the focused window to the bottom third of the screen.",
|
||||||
|
"icon": "font_a-BottomThird",
|
||||||
|
"type": "command"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "Center",
|
||||||
|
"name": "Center",
|
||||||
|
"description": "Center the focused window in the screen.",
|
||||||
|
"icon": "font_Center",
|
||||||
|
"type": "command"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "FirstFourth",
|
||||||
|
"name": "First Fourth",
|
||||||
|
"description": "Resize the focused window to the first fourth of the screen.",
|
||||||
|
"icon": "font_a-FirstFourth",
|
||||||
|
"type": "command"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "SecondFourth",
|
||||||
|
"name": "Second Fourth",
|
||||||
|
"description": "Resize the focused window to the second fourth of the screen.",
|
||||||
|
"icon": "font_a-SecondFourth",
|
||||||
|
"type": "command"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ThirdFourth",
|
||||||
|
"name": "Third Fourth",
|
||||||
|
"description": "Resize the focused window to the third fourth of the screen.",
|
||||||
|
"icon": "font_a-ThirdFourth",
|
||||||
|
"type": "command"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "LastFourth",
|
||||||
|
"name": "Last Fourth",
|
||||||
|
"description": "Resize the focused window to the last fourth of the screen.",
|
||||||
|
"icon": "font_a-LastFourth",
|
||||||
|
"type": "command"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "FirstThird",
|
||||||
|
"name": "First Third",
|
||||||
|
"description": "Resize the focused window to the first third of the screen.",
|
||||||
|
"icon": "font_a-FirstThird",
|
||||||
|
"type": "command"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "CenterThird",
|
||||||
|
"name": "Center Third",
|
||||||
|
"description": "Resize the focused window to the center third of the screen.",
|
||||||
|
"icon": "font_a-CenterThird",
|
||||||
|
"type": "command"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "LastThird",
|
||||||
|
"name": "Last Third",
|
||||||
|
"description": "Resize the focused window to the last third of the screen.",
|
||||||
|
"icon": "font_a-LastThird",
|
||||||
|
"type": "command"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "FirstTwoThirds",
|
||||||
|
"name": "First Two Thirds",
|
||||||
|
"description": "Resize the focused window to the first two thirds of the screen.",
|
||||||
|
"icon": "font_a-FirstTwoThirds",
|
||||||
|
"type": "command"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "CenterTwoThirds",
|
||||||
|
"name": "Center Two Thirds",
|
||||||
|
"description": "Resize the focused window to the center two thirds of the screen.",
|
||||||
|
"icon": "font_a-CenterTwoThirds",
|
||||||
|
"type": "command"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "LastTwoThirds",
|
||||||
|
"name": "Last Two Thirds",
|
||||||
|
"description": "Resize the focused window to the last two thirds of the screen.",
|
||||||
|
"icon": "font_a-LastTwoThirds",
|
||||||
|
"type": "command"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "FirstThreeFourths",
|
||||||
|
"name": "First Three Fourths",
|
||||||
|
"description": "Resize the focused window to the first three fourths of the screen.",
|
||||||
|
"icon": "font_a-FirstThreeFourths",
|
||||||
|
"type": "command"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "CenterThreeFourths",
|
||||||
|
"name": "Center Three Fourths",
|
||||||
|
"description": "Resize the focused window to the center three fourths of the screen.",
|
||||||
|
"icon": "font_a-CenterThreeFourths",
|
||||||
|
"type": "command"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "LastThreeFourths",
|
||||||
|
"name": "Last Three Fourths",
|
||||||
|
"description": "Resize the focused window to the last three fourths of the screen.",
|
||||||
|
"icon": "font_a-LastThreeFourths",
|
||||||
|
"type": "command"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "TopThreeFourths",
|
||||||
|
"name": "Top Three Fourths",
|
||||||
|
"description": "Resize the focused window to the top three fourths of the screen.",
|
||||||
|
"icon": "font_a-TopThreeFourths",
|
||||||
|
"type": "command"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "BottomThreeFourths",
|
||||||
|
"name": "Bottom Three Fourths",
|
||||||
|
"description": "Resize the focused window to the bottom three fourths of the screen.",
|
||||||
|
"icon": "font_a-BottomThreeFourths",
|
||||||
|
"type": "command"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "TopTwoThirds",
|
||||||
|
"name": "Top Two Thirds",
|
||||||
|
"description": "Resize the focused window to the top two thirds of the screen.",
|
||||||
|
"icon": "font_a-TopTwoThirds",
|
||||||
|
"type": "command"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "BottomTwoThirds",
|
||||||
|
"name": "Bottom Two Thirds",
|
||||||
|
"description": "Resize the focused window to the bottom two thirds of the screen.",
|
||||||
|
"icon": "font_a-BottomTwoThirds",
|
||||||
|
"type": "command"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "TopCenterTwoThirds",
|
||||||
|
"name": "Top Center Two Thirds",
|
||||||
|
"description": "Resize the focused window to the top center two thirds of the screen.",
|
||||||
|
"icon": "font_a-TopCenterTwoThirds",
|
||||||
|
"type": "command"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "TopFirstFourth",
|
||||||
|
"name": "Top First Fourth",
|
||||||
|
"description": "Resize the focused window to the top first fourth of the screen.",
|
||||||
|
"icon": "font_a-TopFirstFourth",
|
||||||
|
"type": "command"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "TopSecondFourth",
|
||||||
|
"name": "Top Second Fourth",
|
||||||
|
"description": "Resize the focused window to the top second fourth of the screen.",
|
||||||
|
"icon": "font_a-TopSecondFourth",
|
||||||
|
"type": "command"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "TopThirdFourth",
|
||||||
|
"name": "Top Third Fourth",
|
||||||
|
"description": "Resize the focused window to the top third fourth of the screen.",
|
||||||
|
"icon": "font_a-TopThirdFourth",
|
||||||
|
"type": "command"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "TopLastFourth",
|
||||||
|
"name": "Top Last Fourth",
|
||||||
|
"description": "Resize the focused window to the top last fourth of the screen.",
|
||||||
|
"icon": "font_a-TopLastFourth",
|
||||||
|
"type": "command"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "MakeLarger",
|
||||||
|
"name": "Make Larger",
|
||||||
|
"description": "Increase the focused window until it reaches the screen size.",
|
||||||
|
"icon": "font_a-MakeLarger",
|
||||||
|
"type": "command"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "MakeSmaller",
|
||||||
|
"name": "Make Smaller",
|
||||||
|
"description": "Decrease the focused window until it reaches its minimal size.",
|
||||||
|
"icon": "font_a-MakeSmaller",
|
||||||
|
"type": "command"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "AlmostMaximize",
|
||||||
|
"name": "Almost Maximize",
|
||||||
|
"description": "Maximize the focused window to almost fit the screen.",
|
||||||
|
"icon": "font_a-AlmostMaximize",
|
||||||
|
"type": "command"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "MaximizeWidth",
|
||||||
|
"name": "Maximize Width",
|
||||||
|
"description": "Maximize width of the focused window to fit the screen.",
|
||||||
|
"icon": "font_a-MaximizeWidth",
|
||||||
|
"type": "command"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "MaximizeHeight",
|
||||||
|
"name": "Maximize Height",
|
||||||
|
"description": "Maximize height of the focused window to fit the screen.",
|
||||||
|
"icon": "font_a-MaximizeHeight",
|
||||||
|
"type": "command"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "MoveUp",
|
||||||
|
"name": "Move Up",
|
||||||
|
"description": "Move the focused window to the top edge of the screen.",
|
||||||
|
"icon": "font_a-MoveUp",
|
||||||
|
"type": "command"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "MoveDown",
|
||||||
|
"name": "Move Down",
|
||||||
|
"description": "Move the focused window to the bottom of the screen.",
|
||||||
|
"icon": "font_a-MoveDown",
|
||||||
|
"type": "command"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "MoveLeft",
|
||||||
|
"name": "Move Left",
|
||||||
|
"description": "Move the focused window to the left edge of the screen.",
|
||||||
|
"icon": "font_a-MoveLeft",
|
||||||
|
"type": "command"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "MoveRight",
|
||||||
|
"name": "Move Right",
|
||||||
|
"description": "Move the focused window to the right edge of the screen.",
|
||||||
|
"icon": "font_a-MoveRight",
|
||||||
|
"type": "command"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "NextDesktop",
|
||||||
|
"name": "Next Desktop",
|
||||||
|
"description": "Move the focused window to the next desktop.",
|
||||||
|
"icon": "font_a-NextDesktop",
|
||||||
|
"type": "command"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "PreviousDesktop",
|
||||||
|
"name": "Previous Desktop",
|
||||||
|
"description": "Move the focused window to the previous desktop.",
|
||||||
|
"icon": "font_a-PreviousDesktop",
|
||||||
|
"type": "command"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "NextDisplay",
|
||||||
|
"name": "Next Display",
|
||||||
|
"description": "Move the focused window to the next display.",
|
||||||
|
"icon": "font_a-NextDisplay",
|
||||||
|
"type": "command"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "PreviousDisplay",
|
||||||
|
"name": "Previous Display",
|
||||||
|
"description": "Move the focused window to the previous display.",
|
||||||
|
"icon": "font_a-PreviousDisplay",
|
||||||
|
"type": "command"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "Restore",
|
||||||
|
"name": "Restore",
|
||||||
|
"description": "Restore the focused window to its last position.",
|
||||||
|
"icon": "font_Restore",
|
||||||
|
"type": "command"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ToggleFullscreen",
|
||||||
|
"name": "Toggle Fullscreen",
|
||||||
|
"description": "Toggle fullscreen mode.",
|
||||||
|
"icon": "font_a-ToggleFullscreen",
|
||||||
|
"type": "command"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
use super::EXTENSION_ID;
|
||||||
|
use crate::common::document::{DataSourceReference, Document};
|
||||||
|
use crate::common::{
|
||||||
|
error::SearchError,
|
||||||
|
search::{QueryResponse, QuerySource, SearchQuery},
|
||||||
|
traits::SearchSource,
|
||||||
|
};
|
||||||
|
use crate::extension::built_in::{get_built_in_extension_directory, load_extension_from_json_file};
|
||||||
|
use crate::extension::{ExtensionType, LOCAL_QUERY_SOURCE_TYPE, calculate_text_similarity};
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use hostname;
|
||||||
|
use tauri::AppHandle;
|
||||||
|
|
||||||
|
/// A search source to allow users to search WM actions.
|
||||||
|
pub(crate) struct WindowManagementSearchSource;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl SearchSource for WindowManagementSearchSource {
|
||||||
|
fn get_type(&self) -> QuerySource {
|
||||||
|
QuerySource {
|
||||||
|
r#type: LOCAL_QUERY_SOURCE_TYPE.into(),
|
||||||
|
name: hostname::get()
|
||||||
|
.unwrap_or(EXTENSION_ID.into())
|
||||||
|
.to_string_lossy()
|
||||||
|
.into(),
|
||||||
|
id: EXTENSION_ID.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn search(
|
||||||
|
&self,
|
||||||
|
tauri_app_handle: AppHandle,
|
||||||
|
query: SearchQuery,
|
||||||
|
) -> Result<QueryResponse, SearchError> {
|
||||||
|
let Some(query_string) = query.query_strings.get("query") else {
|
||||||
|
return Ok(QueryResponse {
|
||||||
|
source: self.get_type(),
|
||||||
|
hits: Vec::new(),
|
||||||
|
total_hits: 0,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
let from = usize::try_from(query.from).expect("from too big");
|
||||||
|
let size = usize::try_from(query.size).expect("size too big");
|
||||||
|
|
||||||
|
let query_string = query_string.trim();
|
||||||
|
if query_string.is_empty() {
|
||||||
|
return Ok(QueryResponse {
|
||||||
|
source: self.get_type(),
|
||||||
|
hits: Vec::new(),
|
||||||
|
total_hits: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let query_string_lowercase = query_string.to_lowercase();
|
||||||
|
|
||||||
|
let extension = load_extension_from_json_file(
|
||||||
|
&get_built_in_extension_directory(&tauri_app_handle),
|
||||||
|
super::EXTENSION_ID,
|
||||||
|
)
|
||||||
|
.map_err(SearchError::InternalError)?;
|
||||||
|
let commands = extension.commands.expect("this extension has commands");
|
||||||
|
|
||||||
|
let mut hits: Vec<(Document, f64)> = Vec::new();
|
||||||
|
|
||||||
|
// We know they are all commands
|
||||||
|
let command_type_string = ExtensionType::Command.to_string();
|
||||||
|
for command in commands.iter().filter(|ext| ext.enabled) {
|
||||||
|
let score = {
|
||||||
|
let mut score = 0_f64;
|
||||||
|
|
||||||
|
if let Some(name_score) =
|
||||||
|
calculate_text_similarity(&query_string_lowercase, &command.name.to_lowercase())
|
||||||
|
{
|
||||||
|
score += name_score;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ref alias) = command.alias {
|
||||||
|
if let Some(alias_score) =
|
||||||
|
calculate_text_similarity(&query_string_lowercase, &alias.to_lowercase())
|
||||||
|
{
|
||||||
|
score += alias_score;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
score
|
||||||
|
};
|
||||||
|
|
||||||
|
if score > 0.0 {
|
||||||
|
let on_opened = super::on_opened::on_opened(&command.id);
|
||||||
|
let url = on_opened.url();
|
||||||
|
|
||||||
|
let document = Document {
|
||||||
|
id: command.id.clone(),
|
||||||
|
title: Some(command.name.clone()),
|
||||||
|
icon: Some(command.icon.clone()),
|
||||||
|
on_opened: Some(on_opened),
|
||||||
|
url: Some(url),
|
||||||
|
category: Some(command_type_string.clone()),
|
||||||
|
source: Some(DataSourceReference {
|
||||||
|
id: Some(command_type_string.clone()),
|
||||||
|
name: Some(command_type_string.clone()),
|
||||||
|
icon: None,
|
||||||
|
r#type: Some(command_type_string.clone()),
|
||||||
|
}),
|
||||||
|
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
hits.push((document, score));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hits.sort_by(|(_, score_a), (_, score_b)| {
|
||||||
|
score_a
|
||||||
|
.partial_cmp(&score_b)
|
||||||
|
.expect("expect no NAN/INFINITY/...")
|
||||||
|
});
|
||||||
|
|
||||||
|
let total_hits = hits.len();
|
||||||
|
let from_size_applied = hits.into_iter().skip(from).take(size).collect();
|
||||||
|
|
||||||
|
Ok(QueryResponse {
|
||||||
|
source: self.get_type(),
|
||||||
|
hits: from_size_applied,
|
||||||
|
total_hits,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
pub(crate) mod api;
|
||||||
pub(crate) mod built_in;
|
pub(crate) mod built_in;
|
||||||
pub(crate) mod third_party;
|
pub(crate) mod third_party;
|
||||||
|
|
||||||
@@ -7,6 +8,7 @@ use crate::common::document::OnOpened;
|
|||||||
use crate::common::register::SearchSourceRegistry;
|
use crate::common::register::SearchSourceRegistry;
|
||||||
use crate::util::platform::Platform;
|
use crate::util::platform::Platform;
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
|
use bitflags::bitflags;
|
||||||
use borrowme::{Borrow, ToOwned};
|
use borrowme::{Borrow, ToOwned};
|
||||||
use derive_more::Display;
|
use derive_more::Display;
|
||||||
use indexmap::IndexMap;
|
use indexmap::IndexMap;
|
||||||
@@ -78,11 +80,14 @@ pub struct Extension {
|
|||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
quicklink: Option<Quicklink>,
|
quicklink: Option<Quicklink>,
|
||||||
|
|
||||||
// If this extension is of type Group or Extension, then it behaves like a
|
/*
|
||||||
// directory, i.e., it could contain sub items.
|
* If this extension is of type Group or Extension, then it behaves like a
|
||||||
|
* directory, i.e., it could contain sub items.
|
||||||
|
*/
|
||||||
commands: Option<Vec<Extension>>,
|
commands: Option<Vec<Extension>>,
|
||||||
scripts: Option<Vec<Extension>>,
|
scripts: Option<Vec<Extension>>,
|
||||||
quicklinks: Option<Vec<Extension>>,
|
quicklinks: Option<Vec<Extension>>,
|
||||||
|
views: Option<Vec<Extension>>,
|
||||||
|
|
||||||
/// The alias of the extension.
|
/// The alias of the extension.
|
||||||
///
|
///
|
||||||
@@ -103,7 +108,19 @@ pub struct Extension {
|
|||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
settings: Option<ExtensionSettings>,
|
settings: Option<ExtensionSettings>,
|
||||||
|
|
||||||
// We do not care about these fields, just take it regardless of what it is.
|
/// For View extensions, path to the HTML file/page that coco will load
|
||||||
|
/// and render. Otherwise, `None`.
|
||||||
|
page: Option<String>,
|
||||||
|
|
||||||
|
/// Permission that this extension requires.
|
||||||
|
permission: Option<ExtensionPermission>,
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The following fields are currently useless to us but are needed by our
|
||||||
|
* extension store.
|
||||||
|
*
|
||||||
|
* Since we do not use them, just accept them regardless of what they are.
|
||||||
|
*/
|
||||||
screenshots: Option<Json>,
|
screenshots: Option<Json>,
|
||||||
url: Option<Json>,
|
url: Option<Json>,
|
||||||
version: Option<Json>,
|
version: Option<Json>,
|
||||||
@@ -176,6 +193,7 @@ impl Extension {
|
|||||||
/// `None` if it cannot be opened.
|
/// `None` if it cannot be opened.
|
||||||
pub(crate) fn on_opened(&self) -> Option<OnOpened> {
|
pub(crate) fn on_opened(&self) -> Option<OnOpened> {
|
||||||
let settings = self.settings.clone();
|
let settings = self.settings.clone();
|
||||||
|
let permission = self.permission.clone();
|
||||||
|
|
||||||
match self.r#type {
|
match self.r#type {
|
||||||
// This function, at the time of writing this comment, is primarily
|
// This function, at the time of writing this comment, is primarily
|
||||||
@@ -213,7 +231,11 @@ impl Extension {
|
|||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
let extension_on_opened = ExtensionOnOpened { ty, settings };
|
let extension_on_opened = ExtensionOnOpened {
|
||||||
|
ty,
|
||||||
|
settings,
|
||||||
|
permission,
|
||||||
|
};
|
||||||
|
|
||||||
Some(OnOpened::Extension(extension_on_opened))
|
Some(OnOpened::Extension(extension_on_opened))
|
||||||
}
|
}
|
||||||
@@ -229,12 +251,31 @@ impl Extension {
|
|||||||
open_with: quicklink.open_with,
|
open_with: quicklink.open_with,
|
||||||
};
|
};
|
||||||
|
|
||||||
let extension_on_opened = ExtensionOnOpened { ty, settings };
|
let extension_on_opened = ExtensionOnOpened {
|
||||||
|
ty,
|
||||||
|
settings,
|
||||||
|
permission,
|
||||||
|
};
|
||||||
|
|
||||||
Some(OnOpened::Extension(extension_on_opened))
|
Some(OnOpened::Extension(extension_on_opened))
|
||||||
}
|
}
|
||||||
ExtensionType::Script => todo!("not supported yet"),
|
ExtensionType::Script => todo!("not supported yet"),
|
||||||
ExtensionType::Setting => todo!("not supported yet"),
|
ExtensionType::Setting => todo!("not supported yet"),
|
||||||
|
ExtensionType::View => {
|
||||||
|
let page = self.page.as_ref().unwrap_or_else(|| {
|
||||||
|
panic!("View extension [{}]'s [page] field is not set, something wrong with your extension validity check", self.id);
|
||||||
|
}).clone();
|
||||||
|
|
||||||
|
let extension_on_opened_type = ExtensionOnOpenedType::View { page };
|
||||||
|
let extension_on_opened = ExtensionOnOpened {
|
||||||
|
ty: extension_on_opened_type,
|
||||||
|
settings,
|
||||||
|
permission,
|
||||||
|
};
|
||||||
|
let on_opened = OnOpened::Extension(extension_on_opened);
|
||||||
|
|
||||||
|
Some(on_opened)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,6 +299,11 @@ impl Extension {
|
|||||||
return Some(sub_ext);
|
return Some(sub_ext);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if let Some(ref views) = self.views {
|
||||||
|
if let Some(sub_ext) = views.iter().find(|view| view.id == sub_extension_id) {
|
||||||
|
return Some(sub_ext);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
@@ -288,6 +334,11 @@ impl Extension {
|
|||||||
return Some(sub_ext);
|
return Some(sub_ext);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if let Some(ref mut views) = self.views {
|
||||||
|
if let Some(sub_ext) = views.iter_mut().find(|view| view.id == sub_extension_id) {
|
||||||
|
return Some(sub_ext);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
@@ -497,6 +548,8 @@ pub enum ExtensionType {
|
|||||||
Calculator,
|
Calculator,
|
||||||
#[display("AI Extension")]
|
#[display("AI Extension")]
|
||||||
AiExtension,
|
AiExtension,
|
||||||
|
#[display("View")]
|
||||||
|
View,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ExtensionType {
|
impl ExtensionType {
|
||||||
@@ -528,6 +581,9 @@ fn filter_out_extensions(
|
|||||||
if let Some(ref mut quicklinks) = extension.quicklinks {
|
if let Some(ref mut quicklinks) = extension.quicklinks {
|
||||||
quicklinks.retain(|link| link.enabled);
|
quicklinks.retain(|link| link.enabled);
|
||||||
}
|
}
|
||||||
|
if let Some(ref mut views) = extension.views {
|
||||||
|
views.retain(|link| link.enabled);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -556,6 +612,9 @@ fn filter_out_extensions(
|
|||||||
if let Some(ref mut quicklinks) = extension.quicklinks {
|
if let Some(ref mut quicklinks) = extension.quicklinks {
|
||||||
quicklinks.retain(|link| link.r#type == extension_type);
|
quicklinks.retain(|link| link.r#type == extension_type);
|
||||||
}
|
}
|
||||||
|
if let Some(ref mut views) = extension.views {
|
||||||
|
views.retain(|link| link.r#type == extension_type);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -606,6 +665,9 @@ fn filter_out_extensions(
|
|||||||
if let Some(ref mut quicklinks) = extension.quicklinks {
|
if let Some(ref mut quicklinks) = extension.quicklinks {
|
||||||
quicklinks.retain(&match_closure);
|
quicklinks.retain(&match_closure);
|
||||||
}
|
}
|
||||||
|
if let Some(ref mut views) = extension.views {
|
||||||
|
views.retain(&match_closure);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -773,7 +835,7 @@ pub(crate) async fn set_extension_alias(
|
|||||||
let bundle_id_borrowed = bundle_id.borrow();
|
let bundle_id_borrowed = bundle_id.borrow();
|
||||||
|
|
||||||
if built_in::is_extension_built_in(&bundle_id_borrowed) {
|
if built_in::is_extension_built_in(&bundle_id_borrowed) {
|
||||||
built_in::set_built_in_extension_alias(&tauri_app_handle, &bundle_id_borrowed, &alias);
|
built_in::set_built_in_extension_alias(&tauri_app_handle, &bundle_id_borrowed, &alias)?;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
third_party::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE.get().expect("global third party search source not set, looks like init_extensions() has not been executed").set_extension_alias(&tauri_app_handle, &bundle_id_borrowed, &alias).await
|
third_party::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE.get().expect("global third party search source not set, looks like init_extensions() has not been executed").set_extension_alias(&tauri_app_handle, &bundle_id_borrowed, &alias).await
|
||||||
@@ -880,6 +942,55 @@ pub(crate) fn canonicalize_relative_icon_path(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(views) = &mut extension.views {
|
||||||
|
for view in views {
|
||||||
|
_canonicalize_relative_icon_path(extension_dir, view)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn canonicalize_relative_page_path(
|
||||||
|
extension_dir: &Path,
|
||||||
|
extension: &mut Extension,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
fn _canonicalize_view_extension_page_path(
|
||||||
|
extension_dir: &Path,
|
||||||
|
extension: &mut Extension,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let page = extension
|
||||||
|
.page
|
||||||
|
.as_ref()
|
||||||
|
.expect("this should be invoked on a View extension");
|
||||||
|
let page_path = Path::new(page);
|
||||||
|
|
||||||
|
if page_path.is_relative() {
|
||||||
|
let absolute_page_path = extension_dir.join(page_path);
|
||||||
|
|
||||||
|
if absolute_page_path.try_exists().map_err(|e| e.to_string())? {
|
||||||
|
extension.page = Some(
|
||||||
|
absolute_page_path
|
||||||
|
.into_os_string()
|
||||||
|
.into_string()
|
||||||
|
.expect("path should be UTF-8 encoded"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
if extension.r#type == ExtensionType::View {
|
||||||
|
_canonicalize_view_extension_page_path(extension_dir, extension)?;
|
||||||
|
} else if extension.r#type.contains_sub_items()
|
||||||
|
&& let Some(ref mut views) = extension.views
|
||||||
|
{
|
||||||
|
for view in views {
|
||||||
|
_canonicalize_view_extension_page_path(extension_dir, view)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -932,6 +1043,14 @@ fn alter_extension_json_file(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Search in views
|
||||||
|
if let Some(ref mut views) = root_extension.views {
|
||||||
|
if let Some(view) = views.iter_mut().find(|v| v.id == sub_extension_id) {
|
||||||
|
how(view)?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Err(format!(
|
Err(format!(
|
||||||
"extension [{:?}] not found in {:?}",
|
"extension [{:?}] not found in {:?}",
|
||||||
bundle_id, root_extension
|
bundle_id, root_extension
|
||||||
@@ -1111,6 +1230,119 @@ pub(crate) struct ExtensionSettings {
|
|||||||
pub(crate) hide_before_open: Option<bool>,
|
pub(crate) hide_before_open: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub(crate) struct ExtensionPermission {
|
||||||
|
fs: Option<Vec<ExtensionFileSystemPermission>>,
|
||||||
|
http: Option<Vec<ExtensionHttpPermission>>,
|
||||||
|
api: Option<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub(crate) struct ExtensionFileSystemPermission {
|
||||||
|
pub(crate) path: String,
|
||||||
|
pub(crate) access: FileSystemAccess,
|
||||||
|
}
|
||||||
|
|
||||||
|
bitflags! {
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
pub(crate) struct FileSystemAccess: u8 {
|
||||||
|
const READ = 0b00000001;
|
||||||
|
const WRITE = 0b00000010;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Serialize for FileSystemAccess {
|
||||||
|
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||||
|
where
|
||||||
|
S: serde::Serializer,
|
||||||
|
{
|
||||||
|
let mut access_vec = Vec::new();
|
||||||
|
if self.contains(FileSystemAccess::READ) {
|
||||||
|
access_vec.push("read");
|
||||||
|
}
|
||||||
|
if self.contains(FileSystemAccess::WRITE) {
|
||||||
|
access_vec.push("write");
|
||||||
|
}
|
||||||
|
access_vec.serialize(serializer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> Deserialize<'de> for FileSystemAccess {
|
||||||
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||||
|
where
|
||||||
|
D: serde::Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let access_vec: Vec<String> = Vec::deserialize(deserializer)?;
|
||||||
|
let mut access = FileSystemAccess::empty();
|
||||||
|
|
||||||
|
for access_type in access_vec {
|
||||||
|
match access_type.as_str() {
|
||||||
|
"read" => access |= FileSystemAccess::READ,
|
||||||
|
"write" => access |= FileSystemAccess::WRITE,
|
||||||
|
_ => {
|
||||||
|
return Err(serde::de::Error::unknown_variant(
|
||||||
|
access_type.as_str(),
|
||||||
|
&["read", "write"],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(access)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub(crate) struct ExtensionHttpPermission {
|
||||||
|
pub(crate) host: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculates a similarity score between a query and a text, aiming for a [0, 1] range.
|
||||||
|
/// Assumes query and text are already lowercased.
|
||||||
|
///
|
||||||
|
/// Used in extension_to_hit().
|
||||||
|
fn calculate_text_similarity(query: &str, text: &str) -> Option<f64> {
|
||||||
|
if query.is_empty() || text.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
if text == query {
|
||||||
|
return Some(1.0); // Perfect match
|
||||||
|
}
|
||||||
|
|
||||||
|
let query_len = query.len() as f64;
|
||||||
|
let text_len = text.len() as f64;
|
||||||
|
let ratio = query_len / text_len;
|
||||||
|
let mut score: f64 = 0.0;
|
||||||
|
|
||||||
|
// Case 1: Text starts with the query (prefix match)
|
||||||
|
// Score: base 0.5, bonus up to 0.4 for how much of `text` is covered by `query`. Max 0.9.
|
||||||
|
if text.starts_with(query) {
|
||||||
|
score = score.max(0.5 + 0.4 * ratio);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Case 2: Text contains the query (substring match, not necessarily prefix)
|
||||||
|
// Score: base 0.3, bonus up to 0.3. Max 0.6.
|
||||||
|
// `score.max` ensures that if it's both a prefix and contains, the higher score (prefix) is taken.
|
||||||
|
if text.contains(query) {
|
||||||
|
score = score.max(0.3 + 0.3 * ratio);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Case 3: Fallback for "all query characters exist in text" (order-independent)
|
||||||
|
if score < 0.2 {
|
||||||
|
if query.chars().all(|c_q| text.contains(c_q)) {
|
||||||
|
score = score.max(0.15); // Fixed low score for this weaker match type
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if score > 0.0 {
|
||||||
|
// Cap non-perfect matches slightly below 1.0 to make perfect (1.0) distinct.
|
||||||
|
Some(score.min(0.95))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -1581,4 +1813,234 @@ mod tests {
|
|||||||
let result = link.concatenate_url(&None);
|
let result = link.concatenate_url(&None);
|
||||||
assert_eq!(result, "");
|
assert_eq!(result, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper function for approximate floating point comparison
|
||||||
|
fn approx_eq(a: f64, b: f64) -> bool {
|
||||||
|
(a - b).abs() < 1e-10
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_empty_strings() {
|
||||||
|
assert_eq!(calculate_text_similarity("", "text"), None);
|
||||||
|
assert_eq!(calculate_text_similarity("query", ""), None);
|
||||||
|
assert_eq!(calculate_text_similarity("", ""), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_perfect_match() {
|
||||||
|
assert_eq!(calculate_text_similarity("text", "text"), Some(1.0));
|
||||||
|
assert_eq!(calculate_text_similarity("a", "a"), Some(1.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_prefix_match() {
|
||||||
|
// For "te" and "text":
|
||||||
|
// score = 0.5 + 0.4 * (2/4) = 0.5 + 0.2 = 0.7
|
||||||
|
let score = calculate_text_similarity("te", "text").unwrap();
|
||||||
|
assert!(approx_eq(score, 0.7));
|
||||||
|
|
||||||
|
// For "tex" and "text":
|
||||||
|
// score = 0.5 + 0.4 * (3/4) = 0.5 + 0.3 = 0.8
|
||||||
|
let score = calculate_text_similarity("tex", "text").unwrap();
|
||||||
|
assert!(approx_eq(score, 0.8));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_substring_match() {
|
||||||
|
// For "ex" and "text":
|
||||||
|
// score = 0.3 + 0.3 * (2/4) = 0.3 + 0.15 = 0.45
|
||||||
|
let score = calculate_text_similarity("ex", "text").unwrap();
|
||||||
|
assert!(approx_eq(score, 0.45));
|
||||||
|
|
||||||
|
// Prefix should score higher than substring
|
||||||
|
assert!(
|
||||||
|
calculate_text_similarity("te", "text").unwrap()
|
||||||
|
> calculate_text_similarity("ex", "text").unwrap()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_character_presence() {
|
||||||
|
// Characters present but not in sequence
|
||||||
|
// "tac" in "contact" - not a substring, but all chars exist
|
||||||
|
let score = calculate_text_similarity("tac", "contact").unwrap();
|
||||||
|
assert!(approx_eq(0.3 + 0.3 * (3.0 / 7.0), score));
|
||||||
|
|
||||||
|
assert!(calculate_text_similarity("ac", "contact").is_some());
|
||||||
|
|
||||||
|
// Should not apply if some characters are missing
|
||||||
|
assert_eq!(calculate_text_similarity("xyz", "contact"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_combined_scenarios() {
|
||||||
|
// Test that character presence fallback doesn't override higher scores
|
||||||
|
// "tex" is a prefix of "text" with score 0.8
|
||||||
|
let score = calculate_text_similarity("tex", "text").unwrap();
|
||||||
|
assert!(approx_eq(score, 0.8));
|
||||||
|
|
||||||
|
// Test a case where the characters exist but it's already a substring
|
||||||
|
// "act" is a substring of "contact" with score > 0.2, so fallback won't apply
|
||||||
|
let expected_score = 0.3 + 0.3 * (3.0 / 7.0);
|
||||||
|
let actual_score = calculate_text_similarity("act", "contact").unwrap();
|
||||||
|
assert!(approx_eq(actual_score, expected_score));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_no_similarity() {
|
||||||
|
assert_eq!(calculate_text_similarity("xyz", "test"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_score_capping() {
|
||||||
|
// Use a long query that's a prefix of a slightly longer text
|
||||||
|
let long_text = "abcdefghijklmnopqrstuvwxyz";
|
||||||
|
let long_prefix = "abcdefghijklmnopqrstuvwxy"; // All but last letter
|
||||||
|
|
||||||
|
// Expected score would be 0.5 + 0.4 * (25/26) = 0.5 + 0.385 = 0.885
|
||||||
|
let expected_score = 0.5 + 0.4 * (25.0 / 26.0);
|
||||||
|
let actual_score = calculate_text_similarity(long_prefix, long_text).unwrap();
|
||||||
|
assert!(approx_eq(actual_score, expected_score));
|
||||||
|
|
||||||
|
// Verify that non-perfect matches are capped at 0.95
|
||||||
|
assert!(calculate_text_similarity("almost", "almost perfect").unwrap() <= 0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_filesystem_access_serialize_empty() {
|
||||||
|
let access = FileSystemAccess::empty();
|
||||||
|
let serialized = serde_json::to_string(&access).unwrap();
|
||||||
|
assert_eq!(serialized, "[]");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_filesystem_access_serialize_read_only() {
|
||||||
|
let access = FileSystemAccess::READ;
|
||||||
|
let serialized = serde_json::to_string(&access).unwrap();
|
||||||
|
assert_eq!(serialized, r#"["read"]"#);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_filesystem_access_serialize_write_only() {
|
||||||
|
let access = FileSystemAccess::WRITE;
|
||||||
|
let serialized = serde_json::to_string(&access).unwrap();
|
||||||
|
assert_eq!(serialized, r#"["write"]"#);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_filesystem_access_serialize_read_write() {
|
||||||
|
let access = FileSystemAccess::READ | FileSystemAccess::WRITE;
|
||||||
|
let serialized = serde_json::to_string(&access).unwrap();
|
||||||
|
// The order should be consistent based on our implementation (read first, then write)
|
||||||
|
assert_eq!(serialized, r#"["read","write"]"#);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_filesystem_access_deserialize_empty() {
|
||||||
|
let json = "[]";
|
||||||
|
let access: FileSystemAccess = serde_json::from_str(json).unwrap();
|
||||||
|
assert_eq!(access, FileSystemAccess::empty());
|
||||||
|
assert!(!access.contains(FileSystemAccess::READ));
|
||||||
|
assert!(!access.contains(FileSystemAccess::WRITE));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_filesystem_access_deserialize_read_only() {
|
||||||
|
let json = r#"["read"]"#;
|
||||||
|
let access: FileSystemAccess = serde_json::from_str(json).unwrap();
|
||||||
|
assert_eq!(access, FileSystemAccess::READ);
|
||||||
|
assert!(access.contains(FileSystemAccess::READ));
|
||||||
|
assert!(!access.contains(FileSystemAccess::WRITE));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_filesystem_access_deserialize_write_only() {
|
||||||
|
let json = r#"["write"]"#;
|
||||||
|
let access: FileSystemAccess = serde_json::from_str(json).unwrap();
|
||||||
|
assert_eq!(access, FileSystemAccess::WRITE);
|
||||||
|
assert!(!access.contains(FileSystemAccess::READ));
|
||||||
|
assert!(access.contains(FileSystemAccess::WRITE));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_filesystem_access_deserialize_read_write() {
|
||||||
|
let json = r#"["read", "write"]"#;
|
||||||
|
let access: FileSystemAccess = serde_json::from_str(json).unwrap();
|
||||||
|
assert_eq!(access, FileSystemAccess::READ | FileSystemAccess::WRITE);
|
||||||
|
assert!(access.contains(FileSystemAccess::READ));
|
||||||
|
assert!(access.contains(FileSystemAccess::WRITE));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_filesystem_access_deserialize_write_read_order() {
|
||||||
|
// Test that order doesn't matter during deserialization
|
||||||
|
let json = r#"["write", "read"]"#;
|
||||||
|
let access: FileSystemAccess = serde_json::from_str(json).unwrap();
|
||||||
|
assert_eq!(access, FileSystemAccess::READ | FileSystemAccess::WRITE);
|
||||||
|
assert!(access.contains(FileSystemAccess::READ));
|
||||||
|
assert!(access.contains(FileSystemAccess::WRITE));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_filesystem_access_deserialize_duplicate_values() {
|
||||||
|
// Test that duplicate values don't cause issues
|
||||||
|
let json = r#"["read", "read", "write"]"#;
|
||||||
|
let access: FileSystemAccess = serde_json::from_str(json).unwrap();
|
||||||
|
assert_eq!(access, FileSystemAccess::READ | FileSystemAccess::WRITE);
|
||||||
|
assert!(access.contains(FileSystemAccess::READ));
|
||||||
|
assert!(access.contains(FileSystemAccess::WRITE));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_filesystem_access_deserialize_invalid_value() {
|
||||||
|
let json = r#"["invalid"]"#;
|
||||||
|
let result: Result<FileSystemAccess, _> = serde_json::from_str(json);
|
||||||
|
assert!(result.is_err());
|
||||||
|
|
||||||
|
let error_msg = result.unwrap_err().to_string();
|
||||||
|
assert!(error_msg.contains("invalid"));
|
||||||
|
assert!(error_msg.contains("read") && error_msg.contains("write"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_filesystem_access_deserialize_mixed_valid_invalid() {
|
||||||
|
let json = r#"["read", "invalid", "write"]"#;
|
||||||
|
let result: Result<FileSystemAccess, _> = serde_json::from_str(json);
|
||||||
|
assert!(result.is_err());
|
||||||
|
|
||||||
|
let error_msg = result.unwrap_err().to_string();
|
||||||
|
assert!(error_msg.contains("invalid"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_filesystem_access_round_trip_empty() {
|
||||||
|
let original = FileSystemAccess::empty();
|
||||||
|
let serialized = serde_json::to_string(&original).unwrap();
|
||||||
|
let deserialized: FileSystemAccess = serde_json::from_str(&serialized).unwrap();
|
||||||
|
assert_eq!(original, deserialized);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_filesystem_access_round_trip_read() {
|
||||||
|
let original = FileSystemAccess::READ;
|
||||||
|
let serialized = serde_json::to_string(&original).unwrap();
|
||||||
|
let deserialized: FileSystemAccess = serde_json::from_str(&serialized).unwrap();
|
||||||
|
assert_eq!(original, deserialized);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_filesystem_access_round_trip_write() {
|
||||||
|
let original = FileSystemAccess::WRITE;
|
||||||
|
let serialized = serde_json::to_string(&original).unwrap();
|
||||||
|
let deserialized: FileSystemAccess = serde_json::from_str(&serialized).unwrap();
|
||||||
|
assert_eq!(original, deserialized);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_filesystem_access_round_trip_read_write() {
|
||||||
|
let original = FileSystemAccess::READ | FileSystemAccess::WRITE;
|
||||||
|
let serialized = serde_json::to_string(&original).unwrap();
|
||||||
|
let deserialized: FileSystemAccess = serde_json::from_str(&serialized).unwrap();
|
||||||
|
assert_eq!(original, deserialized);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
76
src-tauri/src/extension/third_party/check.rs
vendored
76
src-tauri/src/extension/third_party/check.rs
vendored
@@ -47,7 +47,11 @@ pub(crate) fn general_check(extension: &Extension) -> Result<(), String> {
|
|||||||
Some(ref v) => v.as_slice(),
|
Some(ref v) => v.as_slice(),
|
||||||
None => &[],
|
None => &[],
|
||||||
};
|
};
|
||||||
let sub_extensions = [commands, scripts, quicklinks].concat();
|
let views = match extension.views {
|
||||||
|
Some(ref v) => v.as_slice(),
|
||||||
|
None => &[],
|
||||||
|
};
|
||||||
|
let sub_extensions = [commands, scripts, quicklinks, views].concat();
|
||||||
let mut sub_extension_ids = HashSet::new();
|
let mut sub_extension_ids = HashSet::new();
|
||||||
|
|
||||||
for sub_extension in sub_extensions.iter() {
|
for sub_extension in sub_extensions.iter() {
|
||||||
@@ -93,7 +97,10 @@ fn check_main_extension_only(extension: &Extension) -> Result<(), String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if extension.commands.is_some() || extension.scripts.is_some() || extension.quicklinks.is_some()
|
if extension.commands.is_some()
|
||||||
|
|| extension.scripts.is_some()
|
||||||
|
|| extension.quicklinks.is_some()
|
||||||
|
|| extension.views.is_some()
|
||||||
{
|
{
|
||||||
if extension.r#type != ExtensionType::Group && extension.r#type != ExtensionType::Extension
|
if extension.r#type != ExtensionType::Group && extension.r#type != ExtensionType::Extension
|
||||||
{
|
{
|
||||||
@@ -134,9 +141,10 @@ fn check_sub_extension_only(
|
|||||||
if sub_extension.commands.is_some()
|
if sub_extension.commands.is_some()
|
||||||
|| sub_extension.scripts.is_some()
|
|| sub_extension.scripts.is_some()
|
||||||
|| sub_extension.quicklinks.is_some()
|
|| sub_extension.quicklinks.is_some()
|
||||||
|
|| sub_extension.views.is_some()
|
||||||
{
|
{
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"invalid sub-extension [{}-{}]: fields [commands/scripts/quicklinks] should not be set in sub-extensions",
|
"invalid sub-extension [{}-{}]: fields [commands/scripts/quicklinks/views] should not be set in sub-extensions",
|
||||||
extension_id, sub_extension.id
|
extension_id, sub_extension.id
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -208,6 +216,21 @@ fn check_main_extension_or_sub_extension(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If field `page` is Some, then it should be a View
|
||||||
|
if extension.page.is_some() && extension.r#type != ExtensionType::View {
|
||||||
|
return Err(format!(
|
||||||
|
"invalid {}, field [page] is set for a non-View extension",
|
||||||
|
identifier
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if extension.r#type == ExtensionType::View && extension.page.is_none() {
|
||||||
|
return Err(format!(
|
||||||
|
"invalid {}, field [page] should be set for a View extension",
|
||||||
|
identifier
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,6 +243,12 @@ mod tests {
|
|||||||
|
|
||||||
/// Helper function to create a basic valid extension
|
/// Helper function to create a basic valid extension
|
||||||
fn create_basic_extension(id: &str, extension_type: ExtensionType) -> Extension {
|
fn create_basic_extension(id: &str, extension_type: ExtensionType) -> Extension {
|
||||||
|
let page = if extension_type == ExtensionType::View {
|
||||||
|
Some("index.html".into())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
Extension {
|
Extension {
|
||||||
id: id.to_string(),
|
id: id.to_string(),
|
||||||
name: "Test Extension".to_string(),
|
name: "Test Extension".to_string(),
|
||||||
@@ -233,9 +262,12 @@ mod tests {
|
|||||||
commands: None,
|
commands: None,
|
||||||
scripts: None,
|
scripts: None,
|
||||||
quicklinks: None,
|
quicklinks: None,
|
||||||
|
views: None,
|
||||||
alias: None,
|
alias: None,
|
||||||
hotkey: None,
|
hotkey: None,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
page,
|
||||||
|
permission: None,
|
||||||
settings: None,
|
settings: None,
|
||||||
screenshots: None,
|
screenshots: None,
|
||||||
url: None,
|
url: None,
|
||||||
@@ -401,6 +433,36 @@ mod tests {
|
|||||||
.contains("field [quicklink] is set for a non-Quicklink extension")
|
.contains("field [quicklink] is set for a non-Quicklink extension")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_view_must_have_page_field() {
|
||||||
|
let mut extension = create_basic_extension("test-view", ExtensionType::View);
|
||||||
|
// create_basic_extension() will set its page field if type is View, clear it
|
||||||
|
extension.page = None;
|
||||||
|
|
||||||
|
let result = general_check(&extension);
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(
|
||||||
|
result
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("field [page] should be set for a View extension")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_non_view_cannot_have_page_field() {
|
||||||
|
let mut extension = create_basic_extension("test-cmd", ExtensionType::Command);
|
||||||
|
extension.action = Some(create_command_action());
|
||||||
|
extension.page = Some("index.html".into());
|
||||||
|
|
||||||
|
let result = general_check(&extension);
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(
|
||||||
|
result
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("field [page] is set for a non-View extension")
|
||||||
|
);
|
||||||
|
}
|
||||||
/* test check_main_extension_or_sub_extension */
|
/* test check_main_extension_or_sub_extension */
|
||||||
|
|
||||||
/* Test check_sub_extension_only */
|
/* Test check_sub_extension_only */
|
||||||
@@ -466,11 +528,9 @@ mod tests {
|
|||||||
|
|
||||||
let result = general_check(&extension);
|
let result = general_check(&extension);
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
assert!(
|
assert!(result.unwrap_err().contains(
|
||||||
result.unwrap_err().contains(
|
"fields [commands/scripts/quicklinks/views] should not be set in sub-extensions"
|
||||||
"fields [commands/scripts/quicklinks] should not be set in sub-extensions"
|
));
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
/* Test check_sub_extension_only */
|
/* Test check_sub_extension_only */
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
use crate::extension::PLUGIN_JSON_FILE_NAME;
|
|
||||||
use crate::extension::third_party::check::general_check;
|
use crate::extension::third_party::check::general_check;
|
||||||
use crate::extension::third_party::install::{
|
use crate::extension::third_party::install::{
|
||||||
filter_out_incompatible_sub_extensions, is_extension_installed,
|
convert_page, filter_out_incompatible_sub_extensions, is_extension_installed,
|
||||||
};
|
};
|
||||||
use crate::extension::third_party::{
|
use crate::extension::third_party::{
|
||||||
THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE, get_third_party_extension_directory,
|
THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE, get_third_party_extension_directory,
|
||||||
};
|
};
|
||||||
use crate::extension::{Extension, canonicalize_relative_icon_path};
|
use crate::extension::{
|
||||||
|
Extension, canonicalize_relative_icon_path, canonicalize_relative_page_path,
|
||||||
|
};
|
||||||
|
use crate::extension::{ExtensionType, PLUGIN_JSON_FILE_NAME};
|
||||||
use crate::util::platform::Platform;
|
use crate::util::platform::Platform;
|
||||||
use serde_json::Value as Json;
|
use serde_json::Value as Json;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
@@ -219,8 +221,56 @@ pub(crate) async fn install_local_extension(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
// Canonicalize relative icon paths
|
/*
|
||||||
|
* Call convert_page() to update the page files. This has to be done after
|
||||||
|
* writing the extension files
|
||||||
|
*/
|
||||||
|
let absolute_page_paths: Vec<PathBuf> = {
|
||||||
|
fn canonicalize_page_path(page_path: &Path, extension_root: &Path) -> PathBuf {
|
||||||
|
if page_path.is_relative() {
|
||||||
|
// It is relative to the extension root directory
|
||||||
|
extension_root.join(page_path)
|
||||||
|
} else {
|
||||||
|
page_path.into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if extension.r#type == ExtensionType::View {
|
||||||
|
let page = extension
|
||||||
|
.page
|
||||||
|
.as_ref()
|
||||||
|
.expect("View extension should set its page field");
|
||||||
|
let path = canonicalize_page_path(Path::new(page.as_str()), &dest_dir);
|
||||||
|
|
||||||
|
vec![path]
|
||||||
|
} else if extension.r#type.contains_sub_items()
|
||||||
|
&& let Some(ref views) = extension.views
|
||||||
|
{
|
||||||
|
let mut paths = Vec::with_capacity(views.len());
|
||||||
|
|
||||||
|
for view in views.iter() {
|
||||||
|
let page = view
|
||||||
|
.page
|
||||||
|
.as_ref()
|
||||||
|
.expect("View extension should set its page field");
|
||||||
|
let path = canonicalize_page_path(Path::new(page.as_str()), &dest_dir);
|
||||||
|
|
||||||
|
paths.push(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
paths
|
||||||
|
} else {
|
||||||
|
// No pages in this extension
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for page_path in absolute_page_paths {
|
||||||
|
convert_page(&page_path).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Canonicalize relative icon and page paths
|
||||||
canonicalize_relative_icon_path(&dest_dir, &mut extension)?;
|
canonicalize_relative_icon_path(&dest_dir, &mut extension)?;
|
||||||
|
canonicalize_relative_page_path(&dest_dir, &mut extension)?;
|
||||||
|
|
||||||
// Add extension to the search source
|
// Add extension to the search source
|
||||||
third_party_ext_list_write_lock.push(extension);
|
third_party_ext_list_write_lock.push(extension);
|
||||||
|
|||||||
494
src-tauri/src/extension/third_party/install/mod.rs
vendored
494
src-tauri/src/extension/third_party/install/mod.rs
vendored
@@ -3,31 +3,47 @@
|
|||||||
//!
|
//!
|
||||||
//! # How
|
//! # How
|
||||||
//!
|
//!
|
||||||
//! Technically, installing an extension involves the following steps:
|
//! Technically, installing an extension involves the following steps. The order
|
||||||
//!
|
//! may vary between implementations.
|
||||||
//! 1. Correct the `plugin.json` JSON if it does not conform to our `struct Extension`
|
|
||||||
//! definition.
|
|
||||||
//!
|
//!
|
||||||
//! 2. Write the extension files to the corresponding location
|
//! 1. Check if it is already installed, if so, return
|
||||||
|
//!
|
||||||
|
//! 2. Correct the `plugin.json` JSON if it does not conform to our `struct
|
||||||
|
//! Extension` definition. This can happen because the JSON written by
|
||||||
|
//! developers is in a simplified form for a better developer experience.
|
||||||
|
//!
|
||||||
|
//! 3. Validate the corrected `plugin.json`
|
||||||
|
//! 1. misc checks
|
||||||
|
//! 2. Platform compatibility check
|
||||||
|
//!
|
||||||
|
//! 4. Write the extension files to the corresponding location
|
||||||
//!
|
//!
|
||||||
//! * developer directory
|
//! * developer directory
|
||||||
//! * extension directory
|
//! * extension directory
|
||||||
//! * assets directory
|
//! * assets directory
|
||||||
//! * various assets files, e.g., "icon.png"
|
//! * various assets files, e.g., "icon.png"
|
||||||
//! * plugin.json file
|
//! * plugin.json file
|
||||||
|
//! * View pages if exist
|
||||||
//!
|
//!
|
||||||
//! 3. Canonicalize the `Extension.icon` fields if they are relative paths
|
//! 5. If this extension contains any View extensions, call `convert_page()`
|
||||||
//! (relative to the `assets` directory)
|
//! on them to make them loadable by Tauri/webview.
|
||||||
//!
|
//!
|
||||||
//! 4. Deserialize the `plugin.json` file to a `struct Extension`, and call
|
//! See `convert_page()` for more info.
|
||||||
//! `THIRD_PARTY_EXTENSIONS_DIRECTORY.add_extension(extension)` to add it to
|
//!
|
||||||
//! the in-memory extension list.
|
//! 6. Canonicalize `Extension.icon` and `Extension.page` fields if they are
|
||||||
|
//! relative paths
|
||||||
|
//!
|
||||||
|
//! * icon: relative to the `assets` directory
|
||||||
|
//! * page: relative to the extension root directory
|
||||||
|
//!
|
||||||
|
//! 7. Add the extension to the in-memory extension list.
|
||||||
|
|
||||||
pub(crate) mod local_extension;
|
pub(crate) mod local_extension;
|
||||||
pub(crate) mod store;
|
pub(crate) mod store;
|
||||||
|
|
||||||
use crate::extension::Extension;
|
use crate::extension::Extension;
|
||||||
use crate::util::platform::Platform;
|
use crate::util::platform::Platform;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
use super::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE;
|
use super::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE;
|
||||||
|
|
||||||
@@ -51,14 +67,16 @@ pub(crate) fn filter_out_incompatible_sub_extensions(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For main extensions, None means all.
|
||||||
|
let main_extension_supported_platforms = extension.platforms.clone().unwrap_or(Platform::all());
|
||||||
|
|
||||||
// Filter commands
|
// Filter commands
|
||||||
if let Some(ref mut commands) = extension.commands {
|
if let Some(ref mut commands) = extension.commands {
|
||||||
commands.retain(|sub_ext| {
|
commands.retain(|sub_ext| {
|
||||||
// If platforms is None, the sub-extension is compatible with all platforms
|
|
||||||
if let Some(ref platforms) = sub_ext.platforms {
|
if let Some(ref platforms) = sub_ext.platforms {
|
||||||
platforms.contains(¤t_platform)
|
platforms.contains(¤t_platform)
|
||||||
} else {
|
} else {
|
||||||
true
|
main_extension_supported_platforms.contains(¤t_platform)
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -66,11 +84,10 @@ pub(crate) fn filter_out_incompatible_sub_extensions(
|
|||||||
// Filter scripts
|
// Filter scripts
|
||||||
if let Some(ref mut scripts) = extension.scripts {
|
if let Some(ref mut scripts) = extension.scripts {
|
||||||
scripts.retain(|sub_ext| {
|
scripts.retain(|sub_ext| {
|
||||||
// If platforms is None, the sub-extension is compatible with all platforms
|
|
||||||
if let Some(ref platforms) = sub_ext.platforms {
|
if let Some(ref platforms) = sub_ext.platforms {
|
||||||
platforms.contains(¤t_platform)
|
platforms.contains(¤t_platform)
|
||||||
} else {
|
} else {
|
||||||
true
|
main_extension_supported_platforms.contains(¤t_platform)
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -78,14 +95,137 @@ pub(crate) fn filter_out_incompatible_sub_extensions(
|
|||||||
// Filter quicklinks
|
// Filter quicklinks
|
||||||
if let Some(ref mut quicklinks) = extension.quicklinks {
|
if let Some(ref mut quicklinks) = extension.quicklinks {
|
||||||
quicklinks.retain(|sub_ext| {
|
quicklinks.retain(|sub_ext| {
|
||||||
// If platforms is None, the sub-extension is compatible with all platforms
|
|
||||||
if let Some(ref platforms) = sub_ext.platforms {
|
if let Some(ref platforms) = sub_ext.platforms {
|
||||||
platforms.contains(¤t_platform)
|
platforms.contains(¤t_platform)
|
||||||
} else {
|
} else {
|
||||||
true
|
main_extension_supported_platforms.contains(¤t_platform)
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Filter views
|
||||||
|
if let Some(ref mut views) = extension.views {
|
||||||
|
views.retain(|sub_ext| {
|
||||||
|
if let Some(ref platforms) = sub_ext.platforms {
|
||||||
|
platforms.contains(¤t_platform)
|
||||||
|
} else {
|
||||||
|
main_extension_supported_platforms.contains(¤t_platform)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert the page file to make it loadable by the Tauri/Webview.
|
||||||
|
pub(crate) async fn convert_page(absolute_page_path: &Path) -> Result<(), String> {
|
||||||
|
assert!(absolute_page_path.is_absolute());
|
||||||
|
|
||||||
|
let page_content = tokio::fs::read_to_string(absolute_page_path)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let new_page_content = _convert_page(&page_content, absolute_page_path)?;
|
||||||
|
|
||||||
|
// overwrite it
|
||||||
|
tokio::fs::write(absolute_page_path, new_page_content)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// NOTE: There is no Rust implementation of `convertFileSrc()` in Tauri. Our
|
||||||
|
/// impl here is based on [comment](https://github.com/tauri-apps/tauri/issues/12022#issuecomment-2572879115)
|
||||||
|
fn convert_file_src(path: &Path) -> Result<String, String> {
|
||||||
|
#[cfg(any(windows, target_os = "android"))]
|
||||||
|
let base = "http://asset.localhost/";
|
||||||
|
#[cfg(not(any(windows, target_os = "android")))]
|
||||||
|
let base = "asset://localhost/";
|
||||||
|
|
||||||
|
let path =
|
||||||
|
dunce::canonicalize(path).map_err(|e| format!("Failed to canonicalize path: {}", e))?;
|
||||||
|
let path_str = path.to_string_lossy();
|
||||||
|
let encoded = urlencoding::encode(&path_str);
|
||||||
|
|
||||||
|
Ok(format!("{base}{encoded}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tauri cannot directly access the file system, to make a file loadable, we
|
||||||
|
/// have to `canonicalize()` and `convertFileSrc()` its path before passing it
|
||||||
|
/// to Tauri.
|
||||||
|
///
|
||||||
|
/// View extension's page is a HTML file that Coco (Tauri) will load, we need
|
||||||
|
/// to process all `<PATH>` tags:
|
||||||
|
///
|
||||||
|
/// 1. `<script type="xxx" crossorigin src="<PATH>"></script>`
|
||||||
|
/// 2. `<a href="<PATH>">xxx</a>`
|
||||||
|
/// 3. `<link rel="xxx" href="<PATH>"/>`
|
||||||
|
/// 4. `<img class="xxx" src="<PATH>" alt="xxx"/>`
|
||||||
|
fn _convert_page(page_content: &str, absolute_page_path: &Path) -> Result<String, String> {
|
||||||
|
use scraper::{Html, Selector};
|
||||||
|
|
||||||
|
/// Helper function.
|
||||||
|
///
|
||||||
|
/// Search `document` for the tag attributes specified by `tag_with_attribute`
|
||||||
|
/// and `tag_attribute`, call `convert_file_src()`, then update the attribute
|
||||||
|
/// value with the function return value.
|
||||||
|
fn modify_tag_attributes(
|
||||||
|
document: &Html,
|
||||||
|
modified_html: &mut String,
|
||||||
|
base_dir: &Path,
|
||||||
|
tag_with_attribute: &str,
|
||||||
|
tag_attribute: &str,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let script_selector = Selector::parse(tag_with_attribute).unwrap();
|
||||||
|
for element in document.select(&script_selector) {
|
||||||
|
if let Some(src) = element.value().attr(tag_attribute) {
|
||||||
|
if !src.starts_with("http://")
|
||||||
|
&& !src.starts_with("https://")
|
||||||
|
&& !src.starts_with("asset://")
|
||||||
|
&& !src.starts_with("http://asset.localhost/")
|
||||||
|
{
|
||||||
|
// It could be a path like "/assets/index-41be3ec9.js", but it
|
||||||
|
// is still a relative path. We need to remove the starting /
|
||||||
|
// or path.join() will think it is an absolute path and does nothing
|
||||||
|
let corrected_src = if src.starts_with('/') { &src[1..] } else { src };
|
||||||
|
|
||||||
|
let full_path = base_dir.join(corrected_src);
|
||||||
|
|
||||||
|
let converted_path = convert_file_src(full_path.as_path())?;
|
||||||
|
*modified_html = modified_html.replace(
|
||||||
|
&format!("{}=\"{}\"", tag_attribute, src),
|
||||||
|
&format!("{}=\"{}\"", tag_attribute, converted_path),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
let base_dir = absolute_page_path
|
||||||
|
.parent()
|
||||||
|
.ok_or_else(|| format!("page path is invalid, it should have a parent path"))?;
|
||||||
|
let document: Html = Html::parse_document(page_content);
|
||||||
|
let mut modified_html: String = page_content.to_string();
|
||||||
|
|
||||||
|
modify_tag_attributes(
|
||||||
|
&document,
|
||||||
|
&mut modified_html,
|
||||||
|
base_dir,
|
||||||
|
"script[src]",
|
||||||
|
"src",
|
||||||
|
)?;
|
||||||
|
modify_tag_attributes(&document, &mut modified_html, base_dir, "a[href]", "href")?;
|
||||||
|
modify_tag_attributes(
|
||||||
|
&document,
|
||||||
|
&mut modified_html,
|
||||||
|
base_dir,
|
||||||
|
"link[href]",
|
||||||
|
"href",
|
||||||
|
)?;
|
||||||
|
modify_tag_attributes(&document, &mut modified_html, base_dir, "img[src]", "src")?;
|
||||||
|
|
||||||
|
Ok(modified_html)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -113,10 +253,13 @@ mod tests {
|
|||||||
commands: None,
|
commands: None,
|
||||||
scripts: None,
|
scripts: None,
|
||||||
quicklinks: None,
|
quicklinks: None,
|
||||||
|
views: None,
|
||||||
alias: None,
|
alias: None,
|
||||||
hotkey: None,
|
hotkey: None,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
settings: None,
|
settings: None,
|
||||||
|
page: None,
|
||||||
|
permission: None,
|
||||||
screenshots: None,
|
screenshots: None,
|
||||||
url: None,
|
url: None,
|
||||||
version: None,
|
version: None,
|
||||||
@@ -154,10 +297,15 @@ mod tests {
|
|||||||
ExtensionType::Script,
|
ExtensionType::Script,
|
||||||
Some(HashSet::from([Platform::Macos])),
|
Some(HashSet::from([Platform::Macos])),
|
||||||
)];
|
)];
|
||||||
|
let views = vec![create_test_extension(
|
||||||
|
ExtensionType::View,
|
||||||
|
Some(HashSet::from([Platform::Macos])),
|
||||||
|
)];
|
||||||
// Set sub extensions
|
// Set sub extensions
|
||||||
main_extension.commands = Some(commands);
|
main_extension.commands = Some(commands);
|
||||||
main_extension.quicklinks = Some(quicklinks);
|
main_extension.quicklinks = Some(quicklinks);
|
||||||
main_extension.scripts = Some(scripts);
|
main_extension.scripts = Some(scripts);
|
||||||
|
main_extension.views = Some(views);
|
||||||
|
|
||||||
// Current platform is Linux, all the sub extensions should be filtered out.
|
// Current platform is Linux, all the sub extensions should be filtered out.
|
||||||
filter_out_incompatible_sub_extensions(&mut main_extension, Platform::Linux);
|
filter_out_incompatible_sub_extensions(&mut main_extension, Platform::Linux);
|
||||||
@@ -166,6 +314,7 @@ mod tests {
|
|||||||
assert!(main_extension.commands.unwrap().is_empty());
|
assert!(main_extension.commands.unwrap().is_empty());
|
||||||
assert!(main_extension.quicklinks.unwrap().is_empty());
|
assert!(main_extension.quicklinks.unwrap().is_empty());
|
||||||
assert!(main_extension.scripts.unwrap().is_empty());
|
assert!(main_extension.scripts.unwrap().is_empty());
|
||||||
|
assert!(main_extension.views.unwrap().is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sub extensions are compatible with all the platforms, nothing to filter out.
|
/// Sub extensions are compatible with all the platforms, nothing to filter out.
|
||||||
@@ -186,10 +335,15 @@ mod tests {
|
|||||||
ExtensionType::Script,
|
ExtensionType::Script,
|
||||||
Some(Platform::all()),
|
Some(Platform::all()),
|
||||||
)];
|
)];
|
||||||
|
let views = vec![create_test_extension(
|
||||||
|
ExtensionType::View,
|
||||||
|
Some(Platform::all()),
|
||||||
|
)];
|
||||||
// Set sub extensions
|
// Set sub extensions
|
||||||
main_extension.commands = Some(commands);
|
main_extension.commands = Some(commands);
|
||||||
main_extension.quicklinks = Some(quicklinks);
|
main_extension.quicklinks = Some(quicklinks);
|
||||||
main_extension.scripts = Some(scripts);
|
main_extension.scripts = Some(scripts);
|
||||||
|
main_extension.views = Some(views);
|
||||||
|
|
||||||
// Current platform is Linux, all the sub extensions should be filtered out.
|
// Current platform is Linux, all the sub extensions should be filtered out.
|
||||||
filter_out_incompatible_sub_extensions(&mut main_extension, Platform::Linux);
|
filter_out_incompatible_sub_extensions(&mut main_extension, Platform::Linux);
|
||||||
@@ -198,19 +352,23 @@ mod tests {
|
|||||||
assert_eq!(main_extension.commands.unwrap().len(), 1);
|
assert_eq!(main_extension.commands.unwrap().len(), 1);
|
||||||
assert_eq!(main_extension.quicklinks.unwrap().len(), 1);
|
assert_eq!(main_extension.quicklinks.unwrap().len(), 1);
|
||||||
assert_eq!(main_extension.scripts.unwrap().len(), 1);
|
assert_eq!(main_extension.scripts.unwrap().len(), 1);
|
||||||
|
assert_eq!(main_extension.views.unwrap().len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// `platforms: None` means all platforms as well
|
// main extension is compatible with all platforms, sub extension's platforms
|
||||||
|
// is None, which means all platforms are supported
|
||||||
{
|
{
|
||||||
let mut main_extension = create_test_extension(ExtensionType::Group, None);
|
let mut main_extension = create_test_extension(ExtensionType::Group, None);
|
||||||
// init sub extensions, which are compatible with all the platforms
|
// init sub extensions, which are compatible with all the platforms
|
||||||
let commands = vec![create_test_extension(ExtensionType::Command, None)];
|
let commands = vec![create_test_extension(ExtensionType::Command, None)];
|
||||||
let quicklinks = vec![create_test_extension(ExtensionType::Quicklink, None)];
|
let quicklinks = vec![create_test_extension(ExtensionType::Quicklink, None)];
|
||||||
let scripts = vec![create_test_extension(ExtensionType::Script, None)];
|
let scripts = vec![create_test_extension(ExtensionType::Script, None)];
|
||||||
|
let views = vec![create_test_extension(ExtensionType::View, None)];
|
||||||
// Set sub extensions
|
// Set sub extensions
|
||||||
main_extension.commands = Some(commands);
|
main_extension.commands = Some(commands);
|
||||||
main_extension.quicklinks = Some(quicklinks);
|
main_extension.quicklinks = Some(quicklinks);
|
||||||
main_extension.scripts = Some(scripts);
|
main_extension.scripts = Some(scripts);
|
||||||
|
main_extension.views = Some(views);
|
||||||
|
|
||||||
// Current platform is Linux, all the sub extensions should be filtered out.
|
// Current platform is Linux, all the sub extensions should be filtered out.
|
||||||
filter_out_incompatible_sub_extensions(&mut main_extension, Platform::Linux);
|
filter_out_incompatible_sub_extensions(&mut main_extension, Platform::Linux);
|
||||||
@@ -219,6 +377,308 @@ mod tests {
|
|||||||
assert_eq!(main_extension.commands.unwrap().len(), 1);
|
assert_eq!(main_extension.commands.unwrap().len(), 1);
|
||||||
assert_eq!(main_extension.quicklinks.unwrap().len(), 1);
|
assert_eq!(main_extension.quicklinks.unwrap().len(), 1);
|
||||||
assert_eq!(main_extension.scripts.unwrap().len(), 1);
|
assert_eq!(main_extension.scripts.unwrap().len(), 1);
|
||||||
|
assert_eq!(main_extension.views.unwrap().len(), 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_main_extension_is_incompatible_sub_extension_platforms_none() {
|
||||||
|
{
|
||||||
|
let mut main_extension =
|
||||||
|
create_test_extension(ExtensionType::Group, Some(HashSet::from([Platform::Macos])));
|
||||||
|
let commands = vec![create_test_extension(ExtensionType::Command, None)];
|
||||||
|
main_extension.commands = Some(commands);
|
||||||
|
filter_out_incompatible_sub_extensions(&mut main_extension, Platform::Linux);
|
||||||
|
assert_eq!(main_extension.commands.unwrap().len(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut main_extension =
|
||||||
|
create_test_extension(ExtensionType::Group, Some(HashSet::from([Platform::Macos])));
|
||||||
|
let scripts = vec![create_test_extension(ExtensionType::Script, None)];
|
||||||
|
main_extension.scripts = Some(scripts);
|
||||||
|
filter_out_incompatible_sub_extensions(&mut main_extension, Platform::Linux);
|
||||||
|
assert_eq!(main_extension.scripts.unwrap().len(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut main_extension =
|
||||||
|
create_test_extension(ExtensionType::Group, Some(HashSet::from([Platform::Macos])));
|
||||||
|
let quicklinks = vec![create_test_extension(ExtensionType::Quicklink, None)];
|
||||||
|
main_extension.quicklinks = Some(quicklinks);
|
||||||
|
filter_out_incompatible_sub_extensions(&mut main_extension, Platform::Linux);
|
||||||
|
assert_eq!(main_extension.quicklinks.unwrap().len(), 0);
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let mut main_extension =
|
||||||
|
create_test_extension(ExtensionType::Group, Some(HashSet::from([Platform::Macos])));
|
||||||
|
let views = vec![create_test_extension(ExtensionType::View, None)];
|
||||||
|
main_extension.views = Some(views);
|
||||||
|
filter_out_incompatible_sub_extensions(&mut main_extension, Platform::Linux);
|
||||||
|
assert_eq!(main_extension.views.unwrap().len(), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_main_extension_compatible_sub_extension_platforms_none() {
|
||||||
|
let mut main_extension =
|
||||||
|
create_test_extension(ExtensionType::Group, Some(HashSet::from([Platform::Macos])));
|
||||||
|
let views = vec![create_test_extension(ExtensionType::View, None)];
|
||||||
|
main_extension.views = Some(views);
|
||||||
|
filter_out_incompatible_sub_extensions(&mut main_extension, Platform::Macos);
|
||||||
|
assert_eq!(main_extension.views.unwrap().len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_convert_page_script_tag() {
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
let temp_dir = TempDir::new().unwrap();
|
||||||
|
let html_file = temp_dir.path().join("test.html");
|
||||||
|
let js_file = temp_dir.path().join("main.js");
|
||||||
|
|
||||||
|
let html_content = r#"<html><body><script src="main.js"></script></body></html>"#;
|
||||||
|
std::fs::write(&html_file, html_content).unwrap();
|
||||||
|
std::fs::write(&js_file, "").unwrap();
|
||||||
|
|
||||||
|
let result = _convert_page(html_content, &html_file).unwrap();
|
||||||
|
|
||||||
|
let path = convert_file_src(&js_file).unwrap();
|
||||||
|
let expected = format!(
|
||||||
|
"<html><body><script src=\"{}\"></script></body></html>",
|
||||||
|
path
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(result, expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_convert_page_script_tag_with_a_root_char() {
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
let temp_dir = TempDir::new().unwrap();
|
||||||
|
let html_file = temp_dir.path().join("test.html");
|
||||||
|
let js_file = temp_dir.path().join("main.js");
|
||||||
|
|
||||||
|
let html_content = r#"<html><body><script src="/main.js"></script></body></html>"#;
|
||||||
|
std::fs::write(&html_file, html_content).unwrap();
|
||||||
|
std::fs::write(&js_file, "").unwrap();
|
||||||
|
|
||||||
|
let result = _convert_page(html_content, &html_file).unwrap();
|
||||||
|
|
||||||
|
let path = convert_file_src(&js_file).unwrap();
|
||||||
|
let expected = format!(
|
||||||
|
"<html><body><script src=\"{}\"></script></body></html>",
|
||||||
|
path
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(result, expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_convert_page_a_tag() {
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
let temp_dir = TempDir::new().unwrap();
|
||||||
|
let html_file = temp_dir.path().join("test.html");
|
||||||
|
let js_file = temp_dir.path().join("main.js");
|
||||||
|
|
||||||
|
let html_content = r#"<html><body><a href="main.js">foo</a></body></html>"#;
|
||||||
|
std::fs::write(&html_file, html_content).unwrap();
|
||||||
|
std::fs::write(&js_file, "").unwrap();
|
||||||
|
|
||||||
|
let result = _convert_page(html_content, &html_file).unwrap();
|
||||||
|
|
||||||
|
let path = convert_file_src(&js_file).unwrap();
|
||||||
|
let expected = format!("<html><body><a href=\"{}\">foo</a></body></html>", path);
|
||||||
|
|
||||||
|
assert_eq!(result, expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_convert_page_a_tag_with_a_root_char() {
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
let temp_dir = TempDir::new().unwrap();
|
||||||
|
let html_file = temp_dir.path().join("test.html");
|
||||||
|
let js_file = temp_dir.path().join("main.js");
|
||||||
|
|
||||||
|
let html_content = r#"<html><body><a href="/main.js">foo</a></body></html>"#;
|
||||||
|
std::fs::write(&html_file, html_content).unwrap();
|
||||||
|
std::fs::write(&js_file, "").unwrap();
|
||||||
|
|
||||||
|
let result = _convert_page(html_content, &html_file).unwrap();
|
||||||
|
|
||||||
|
let path = convert_file_src(&js_file).unwrap();
|
||||||
|
let expected = format!("<html><body><a href=\"{}\">foo</a></body></html>", path);
|
||||||
|
|
||||||
|
assert_eq!(result, expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_convert_page_link_href_tag() {
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
let temp_dir = TempDir::new().unwrap();
|
||||||
|
let html_file = temp_dir.path().join("test.html");
|
||||||
|
let css_file = temp_dir.path().join("main.css");
|
||||||
|
|
||||||
|
let html_content = r#"<html><body><link rel="stylesheet" href="main.css"/></body></html>"#;
|
||||||
|
std::fs::write(&html_file, html_content).unwrap();
|
||||||
|
std::fs::write(&css_file, "").unwrap();
|
||||||
|
|
||||||
|
let result = _convert_page(html_content, &html_file).unwrap();
|
||||||
|
|
||||||
|
let path = convert_file_src(&css_file).unwrap();
|
||||||
|
let expected = format!(
|
||||||
|
"<html><body><link rel=\"stylesheet\" href=\"{}\"/></body></html>",
|
||||||
|
path
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(result, expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_convert_page_link_href_tag_with_a_root_tag() {
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
let temp_dir = TempDir::new().unwrap();
|
||||||
|
let html_file = temp_dir.path().join("test.html");
|
||||||
|
let css_file = temp_dir.path().join("main.css");
|
||||||
|
|
||||||
|
let html_content = r#"<html><body><link rel="stylesheet" href="/main.css"/></body></html>"#;
|
||||||
|
std::fs::write(&html_file, html_content).unwrap();
|
||||||
|
std::fs::write(&css_file, "").unwrap();
|
||||||
|
|
||||||
|
let result = _convert_page(html_content, &html_file).unwrap();
|
||||||
|
|
||||||
|
let path = convert_file_src(&css_file).unwrap();
|
||||||
|
let expected = format!(
|
||||||
|
"<html><body><link rel=\"stylesheet\" href=\"{}\"/></body></html>",
|
||||||
|
path
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(result, expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_convert_page_img_src_tag() {
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
let temp_dir = TempDir::new().unwrap();
|
||||||
|
let html_file = temp_dir.path().join("test.html");
|
||||||
|
let png_file = temp_dir.path().join("main.png");
|
||||||
|
|
||||||
|
let html_content =
|
||||||
|
r#"<html><body> <img class="fit-picture" src="main.png" alt="xxx" /></body></html>"#;
|
||||||
|
std::fs::write(&html_file, html_content).unwrap();
|
||||||
|
std::fs::write(&png_file, "").unwrap();
|
||||||
|
|
||||||
|
let result = _convert_page(html_content, &html_file).unwrap();
|
||||||
|
|
||||||
|
let path = convert_file_src(&png_file).unwrap();
|
||||||
|
let expected = format!(
|
||||||
|
"<html><body> <img class=\"fit-picture\" src=\"{}\" alt=\"xxx\" /></body></html>",
|
||||||
|
path
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(result, expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_convert_page_img_src_tag_with_a_root_tag() {
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
let temp_dir = TempDir::new().unwrap();
|
||||||
|
let html_file = temp_dir.path().join("test.html");
|
||||||
|
let png_file = temp_dir.path().join("main.png");
|
||||||
|
|
||||||
|
let html_content =
|
||||||
|
r#"<html><body> <img class="fit-picture" src="/main.png" alt="xxx" /></body></html>"#;
|
||||||
|
std::fs::write(&html_file, html_content).unwrap();
|
||||||
|
std::fs::write(&png_file, "").unwrap();
|
||||||
|
|
||||||
|
let result = _convert_page(html_content, &html_file).unwrap();
|
||||||
|
|
||||||
|
let path = convert_file_src(&png_file).unwrap();
|
||||||
|
let expected = format!(
|
||||||
|
"<html><body> <img class=\"fit-picture\" src=\"{}\" alt=\"xxx\" /></body></html>",
|
||||||
|
path
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(result, expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_convert_page_contain_both_script_and_a_tags() {
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
let temp_dir = TempDir::new().unwrap();
|
||||||
|
let html_file = temp_dir.path().join("test.html");
|
||||||
|
let js_file = temp_dir.path().join("main.js");
|
||||||
|
|
||||||
|
let html_content =
|
||||||
|
r#"<html><body><a href="main.js">foo</a><script src="main.js"></script></body></html>"#;
|
||||||
|
std::fs::write(&html_file, html_content).unwrap();
|
||||||
|
std::fs::write(&js_file, "").unwrap();
|
||||||
|
|
||||||
|
let result = _convert_page(html_content, &html_file).unwrap();
|
||||||
|
|
||||||
|
let path = convert_file_src(&js_file).unwrap();
|
||||||
|
let expected = format!(
|
||||||
|
"<html><body><a href=\"{}\">foo</a><script src=\"{}\"></script></body></html>",
|
||||||
|
path, path
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(result, expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_convert_page_contain_both_script_and_a_tags_with_root_char() {
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
let temp_dir = TempDir::new().unwrap();
|
||||||
|
let html_file = temp_dir.path().join("test.html");
|
||||||
|
let js_file = temp_dir.path().join("main.js");
|
||||||
|
|
||||||
|
let html_content = r#"<html><body><a href="/main.js">foo</a><script src="/main.js"></script></body></html>"#;
|
||||||
|
std::fs::write(&html_file, html_content).unwrap();
|
||||||
|
std::fs::write(&js_file, "").unwrap();
|
||||||
|
|
||||||
|
let result = _convert_page(html_content, &html_file).unwrap();
|
||||||
|
|
||||||
|
let path = convert_file_src(&js_file).unwrap();
|
||||||
|
let expected = format!(
|
||||||
|
"<html><body><a href=\"{}\">foo</a><script src=\"{}\"></script></body></html>",
|
||||||
|
path, path
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(result, expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_convert_page_empty_html() {
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
let temp_dir = TempDir::new().unwrap();
|
||||||
|
let html_file = temp_dir.path().join("test.html");
|
||||||
|
|
||||||
|
let html_content = "";
|
||||||
|
std::fs::write(&html_file, html_content).unwrap();
|
||||||
|
let result = _convert_page(html_content, &html_file).unwrap();
|
||||||
|
assert!(result.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_convert_page_only_html_tag() {
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
let temp_dir = TempDir::new().unwrap();
|
||||||
|
let html_file = temp_dir.path().join("test.html");
|
||||||
|
|
||||||
|
let html_content = "<html></html>";
|
||||||
|
std::fs::write(&html_file, html_content).unwrap();
|
||||||
|
let result = _convert_page(html_content, &html_file).unwrap();
|
||||||
|
assert_eq!(result, html_content);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,20 +10,24 @@ use crate::common::search::QuerySource;
|
|||||||
use crate::common::search::SearchQuery;
|
use crate::common::search::SearchQuery;
|
||||||
use crate::common::traits::SearchSource;
|
use crate::common::traits::SearchSource;
|
||||||
use crate::extension::Extension;
|
use crate::extension::Extension;
|
||||||
|
use crate::extension::ExtensionType;
|
||||||
use crate::extension::PLUGIN_JSON_FILE_NAME;
|
use crate::extension::PLUGIN_JSON_FILE_NAME;
|
||||||
use crate::extension::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE;
|
use crate::extension::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE;
|
||||||
use crate::extension::canonicalize_relative_icon_path;
|
use crate::extension::canonicalize_relative_icon_path;
|
||||||
|
use crate::extension::canonicalize_relative_page_path;
|
||||||
use crate::extension::third_party::check::general_check;
|
use crate::extension::third_party::check::general_check;
|
||||||
use crate::extension::third_party::get_third_party_extension_directory;
|
use crate::extension::third_party::get_third_party_extension_directory;
|
||||||
|
use crate::extension::third_party::install::convert_page;
|
||||||
use crate::extension::third_party::install::filter_out_incompatible_sub_extensions;
|
use crate::extension::third_party::install::filter_out_incompatible_sub_extensions;
|
||||||
use crate::server::http_client::HttpClient;
|
use crate::server::http_client::HttpClient;
|
||||||
use crate::util::platform::Platform;
|
use crate::util::platform::Platform;
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use http::Method;
|
|
||||||
use reqwest::StatusCode;
|
use reqwest::StatusCode;
|
||||||
use serde_json::Map as JsonObject;
|
use serde_json::Map as JsonObject;
|
||||||
use serde_json::Value as Json;
|
use serde_json::Value as Json;
|
||||||
use std::io::Read;
|
use std::io::Read;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::path::PathBuf;
|
||||||
use tauri::AppHandle;
|
use tauri::AppHandle;
|
||||||
|
|
||||||
const DATA_SOURCE_ID: &str = "Extension Store";
|
const DATA_SOURCE_ID: &str = "Extension Store";
|
||||||
@@ -177,9 +181,10 @@ pub(crate) async fn search_extension(
|
|||||||
pub(crate) async fn extension_detail(
|
pub(crate) async fn extension_detail(
|
||||||
id: String,
|
id: String,
|
||||||
) -> Result<Option<JsonObject<String, Json>>, String> {
|
) -> Result<Option<JsonObject<String, Json>>, String> {
|
||||||
let url = format!("http://dev.infini.cloud:27200/store/extension/{}", id);
|
let path = format!("store/extension/{}", id);
|
||||||
let response =
|
let response = HttpClient::get("default_coco_server", path.as_str(), None)
|
||||||
HttpClient::send_raw_request(Method::GET, url.as_str(), None, None, None).await?;
|
.await
|
||||||
|
.map_err(|e| format!("Failed to send request: {:?}", e))?;
|
||||||
|
|
||||||
if response.status() == StatusCode::NOT_FOUND {
|
if response.status() == StatusCode::NOT_FOUND {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
@@ -394,8 +399,56 @@ pub(crate) async fn install_extension_from_store(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
// Turn it into an absolute path if it is a valid relative path because frontend code need this.
|
/*
|
||||||
|
* Call convert_page() to update the page files. This has to be done after
|
||||||
|
* writing the extension files
|
||||||
|
*/
|
||||||
|
let absolute_page_paths: Vec<PathBuf> = {
|
||||||
|
fn canonicalize_page_path(page_path: &Path, extension_root: &Path) -> PathBuf {
|
||||||
|
if page_path.is_relative() {
|
||||||
|
// It is relative to the extension root directory
|
||||||
|
extension_root.join(page_path)
|
||||||
|
} else {
|
||||||
|
page_path.into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if extension.r#type == ExtensionType::View {
|
||||||
|
let page = extension
|
||||||
|
.page
|
||||||
|
.as_ref()
|
||||||
|
.expect("View extension should set its page field");
|
||||||
|
let path = canonicalize_page_path(Path::new(page.as_str()), &extension_directory);
|
||||||
|
|
||||||
|
vec![path]
|
||||||
|
} else if extension.r#type.contains_sub_items()
|
||||||
|
&& let Some(ref views) = extension.views
|
||||||
|
{
|
||||||
|
let mut paths = Vec::with_capacity(views.len());
|
||||||
|
|
||||||
|
for view in views.iter() {
|
||||||
|
let page = view
|
||||||
|
.page
|
||||||
|
.as_ref()
|
||||||
|
.expect("View extension should set its page field");
|
||||||
|
let path = canonicalize_page_path(Path::new(page.as_str()), &extension_directory);
|
||||||
|
|
||||||
|
paths.push(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
paths
|
||||||
|
} else {
|
||||||
|
// No pages in this extension
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for page_path in absolute_page_paths {
|
||||||
|
convert_page(&page_path).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Canonicalize relative icon and page paths
|
||||||
canonicalize_relative_icon_path(&extension_directory, &mut extension)?;
|
canonicalize_relative_icon_path(&extension_directory, &mut extension)?;
|
||||||
|
canonicalize_relative_page_path(&extension_directory, &mut extension)?;
|
||||||
|
|
||||||
third_party_ext_list_write_lock.push(extension);
|
third_party_ext_list_write_lock.push(extension);
|
||||||
|
|
||||||
|
|||||||
192
src-tauri/src/extension/third_party/mod.rs
vendored
192
src-tauri/src/extension/third_party/mod.rs
vendored
@@ -15,6 +15,8 @@ use crate::common::search::QuerySource;
|
|||||||
use crate::common::search::SearchQuery;
|
use crate::common::search::SearchQuery;
|
||||||
use crate::common::traits::SearchSource;
|
use crate::common::traits::SearchSource;
|
||||||
use crate::extension::ExtensionBundleIdBorrowed;
|
use crate::extension::ExtensionBundleIdBorrowed;
|
||||||
|
use crate::extension::calculate_text_similarity;
|
||||||
|
use crate::extension::canonicalize_relative_page_path;
|
||||||
use crate::util::platform::Platform;
|
use crate::util::platform::Platform;
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use borrowme::ToOwned;
|
use borrowme::ToOwned;
|
||||||
@@ -182,6 +184,7 @@ pub(crate) async fn load_third_party_extensions_from_directory(
|
|||||||
|
|
||||||
// Turn it into an absolute path if it is a valid relative path because frontend code needs this.
|
// Turn it into an absolute path if it is a valid relative path because frontend code needs this.
|
||||||
canonicalize_relative_icon_path(&extension_dir.path(), &mut extension)?;
|
canonicalize_relative_icon_path(&extension_dir.path(), &mut extension)?;
|
||||||
|
canonicalize_relative_page_path(&extension_dir.path(), &mut extension)?;
|
||||||
|
|
||||||
extensions.push(extension);
|
extensions.push(extension);
|
||||||
}
|
}
|
||||||
@@ -289,6 +292,11 @@ impl ThirdPartyExtensionsSearchSource {
|
|||||||
Self::_enable_extension(&tauri_app_handle, quicklink).await?;
|
Self::_enable_extension(&tauri_app_handle, quicklink).await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if let Some(views) = &extension.views {
|
||||||
|
for view in views.iter().filter(|ext| ext.enabled) {
|
||||||
|
Self::_enable_extension(&tauri_app_handle, view).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -331,6 +339,11 @@ impl ThirdPartyExtensionsSearchSource {
|
|||||||
Self::_disable_extension(tauri_app_handle, quicklink).await?;
|
Self::_disable_extension(tauri_app_handle, quicklink).await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if let Some(views) = &extension.views {
|
||||||
|
for view in views.iter().filter(|ext| ext.enabled) {
|
||||||
|
Self::_disable_extension(tauri_app_handle, view).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -775,6 +788,16 @@ impl SearchSource for ThirdPartyExtensionsSearchSource {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(ref views) = extension.views {
|
||||||
|
for view in views.iter().filter(|link| link.enabled) {
|
||||||
|
if let Some(hit) =
|
||||||
|
extension_to_hit(view, &query_lower, opt_data_source.as_deref())
|
||||||
|
{
|
||||||
|
hits.push(hit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
if let Some(hit) =
|
if let Some(hit) =
|
||||||
extension_to_hit(extension, &query_lower, opt_data_source.as_deref())
|
extension_to_hit(extension, &query_lower, opt_data_source.as_deref())
|
||||||
@@ -803,7 +826,20 @@ impl SearchSource for ThirdPartyExtensionsSearchSource {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn extension_to_hit(
|
#[tauri::command]
|
||||||
|
pub(crate) async fn uninstall_extension(
|
||||||
|
tauri_app_handle: AppHandle,
|
||||||
|
developer: String,
|
||||||
|
extension_id: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE
|
||||||
|
.get()
|
||||||
|
.expect("global third party search source not set")
|
||||||
|
.uninstall_extension(&tauri_app_handle, &developer, &extension_id)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn extension_to_hit(
|
||||||
extension: &Extension,
|
extension: &Extension,
|
||||||
query_lower: &str,
|
query_lower: &str,
|
||||||
opt_data_source: Option<&str>,
|
opt_data_source: Option<&str>,
|
||||||
@@ -872,157 +908,3 @@ fn extension_to_hit(
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculates a similarity score between a query and a text, aiming for a [0, 1] range.
|
|
||||||
// Assumes query and text are already lowercased.
|
|
||||||
fn calculate_text_similarity(query: &str, text: &str) -> Option<f64> {
|
|
||||||
if query.is_empty() || text.is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
if text == query {
|
|
||||||
return Some(1.0); // Perfect match
|
|
||||||
}
|
|
||||||
|
|
||||||
let query_len = query.len() as f64;
|
|
||||||
let text_len = text.len() as f64;
|
|
||||||
let ratio = query_len / text_len;
|
|
||||||
let mut score: f64 = 0.0;
|
|
||||||
|
|
||||||
// Case 1: Text starts with the query (prefix match)
|
|
||||||
// Score: base 0.5, bonus up to 0.4 for how much of `text` is covered by `query`. Max 0.9.
|
|
||||||
if text.starts_with(query) {
|
|
||||||
score = score.max(0.5 + 0.4 * ratio);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Case 2: Text contains the query (substring match, not necessarily prefix)
|
|
||||||
// Score: base 0.3, bonus up to 0.3. Max 0.6.
|
|
||||||
// `score.max` ensures that if it's both a prefix and contains, the higher score (prefix) is taken.
|
|
||||||
if text.contains(query) {
|
|
||||||
score = score.max(0.3 + 0.3 * ratio);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Case 3: Fallback for "all query characters exist in text" (order-independent)
|
|
||||||
if score < 0.2 {
|
|
||||||
if query.chars().all(|c_q| text.contains(c_q)) {
|
|
||||||
score = score.max(0.15); // Fixed low score for this weaker match type
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if score > 0.0 {
|
|
||||||
// Cap non-perfect matches slightly below 1.0 to make perfect (1.0) distinct.
|
|
||||||
Some(score.min(0.95))
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub(crate) async fn uninstall_extension(
|
|
||||||
tauri_app_handle: AppHandle,
|
|
||||||
developer: String,
|
|
||||||
extension_id: String,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE
|
|
||||||
.get()
|
|
||||||
.expect("global third party search source not set")
|
|
||||||
.uninstall_extension(&tauri_app_handle, &developer, &extension_id)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
// Helper function for approximate floating point comparison
|
|
||||||
fn approx_eq(a: f64, b: f64) -> bool {
|
|
||||||
(a - b).abs() < 1e-10
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_empty_strings() {
|
|
||||||
assert_eq!(calculate_text_similarity("", "text"), None);
|
|
||||||
assert_eq!(calculate_text_similarity("query", ""), None);
|
|
||||||
assert_eq!(calculate_text_similarity("", ""), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_perfect_match() {
|
|
||||||
assert_eq!(calculate_text_similarity("text", "text"), Some(1.0));
|
|
||||||
assert_eq!(calculate_text_similarity("a", "a"), Some(1.0));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_prefix_match() {
|
|
||||||
// For "te" and "text":
|
|
||||||
// score = 0.5 + 0.4 * (2/4) = 0.5 + 0.2 = 0.7
|
|
||||||
let score = calculate_text_similarity("te", "text").unwrap();
|
|
||||||
assert!(approx_eq(score, 0.7));
|
|
||||||
|
|
||||||
// For "tex" and "text":
|
|
||||||
// score = 0.5 + 0.4 * (3/4) = 0.5 + 0.3 = 0.8
|
|
||||||
let score = calculate_text_similarity("tex", "text").unwrap();
|
|
||||||
assert!(approx_eq(score, 0.8));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_substring_match() {
|
|
||||||
// For "ex" and "text":
|
|
||||||
// score = 0.3 + 0.3 * (2/4) = 0.3 + 0.15 = 0.45
|
|
||||||
let score = calculate_text_similarity("ex", "text").unwrap();
|
|
||||||
assert!(approx_eq(score, 0.45));
|
|
||||||
|
|
||||||
// Prefix should score higher than substring
|
|
||||||
assert!(
|
|
||||||
calculate_text_similarity("te", "text").unwrap()
|
|
||||||
> calculate_text_similarity("ex", "text").unwrap()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_character_presence() {
|
|
||||||
// Characters present but not in sequence
|
|
||||||
// "tac" in "contact" - not a substring, but all chars exist
|
|
||||||
let score = calculate_text_similarity("tac", "contact").unwrap();
|
|
||||||
assert!(approx_eq(0.3 + 0.3 * (3.0 / 7.0), score));
|
|
||||||
|
|
||||||
assert!(calculate_text_similarity("ac", "contact").is_some());
|
|
||||||
|
|
||||||
// Should not apply if some characters are missing
|
|
||||||
assert_eq!(calculate_text_similarity("xyz", "contact"), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_combined_scenarios() {
|
|
||||||
// Test that character presence fallback doesn't override higher scores
|
|
||||||
// "tex" is a prefix of "text" with score 0.8
|
|
||||||
let score = calculate_text_similarity("tex", "text").unwrap();
|
|
||||||
assert!(approx_eq(score, 0.8));
|
|
||||||
|
|
||||||
// Test a case where the characters exist but it's already a substring
|
|
||||||
// "act" is a substring of "contact" with score > 0.2, so fallback won't apply
|
|
||||||
let expected_score = 0.3 + 0.3 * (3.0 / 7.0);
|
|
||||||
let actual_score = calculate_text_similarity("act", "contact").unwrap();
|
|
||||||
assert!(approx_eq(actual_score, expected_score));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_no_similarity() {
|
|
||||||
assert_eq!(calculate_text_similarity("xyz", "test"), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_score_capping() {
|
|
||||||
// Use a long query that's a prefix of a slightly longer text
|
|
||||||
let long_text = "abcdefghijklmnopqrstuvwxyz";
|
|
||||||
let long_prefix = "abcdefghijklmnopqrstuvwxy"; // All but last letter
|
|
||||||
|
|
||||||
// Expected score would be 0.5 + 0.4 * (25/26) = 0.5 + 0.385 = 0.885
|
|
||||||
let expected_score = 0.5 + 0.4 * (25.0 / 26.0);
|
|
||||||
let actual_score = calculate_text_similarity(long_prefix, long_text).unwrap();
|
|
||||||
assert!(approx_eq(actual_score, expected_score));
|
|
||||||
|
|
||||||
// Verify that non-perfect matches are capped at 0.95
|
|
||||||
assert!(calculate_text_similarity("almost", "almost perfect").unwrap() <= 0.95);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ mod util;
|
|||||||
use crate::common::register::SearchSourceRegistry;
|
use crate::common::register::SearchSourceRegistry;
|
||||||
use crate::common::{CHECK_WINDOW_LABEL, MAIN_WINDOW_LABEL, SETTINGS_WINDOW_LABEL};
|
use crate::common::{CHECK_WINDOW_LABEL, MAIN_WINDOW_LABEL, SETTINGS_WINDOW_LABEL};
|
||||||
use crate::server::servers::{load_or_insert_default_server, load_servers_token};
|
use crate::server::servers::{load_or_insert_default_server, load_servers_token};
|
||||||
|
use crate::util::prevent_default;
|
||||||
use autostart::change_autostart;
|
use autostart::change_autostart;
|
||||||
use lazy_static::lazy_static;
|
use lazy_static::lazy_static;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
@@ -92,7 +93,8 @@ pub fn run() {
|
|||||||
.build(),
|
.build(),
|
||||||
)
|
)
|
||||||
.plugin(tauri_plugin_windows_version::init())
|
.plugin(tauri_plugin_windows_version::init())
|
||||||
.plugin(tauri_plugin_opener::init());
|
.plugin(tauri_plugin_opener::init())
|
||||||
|
.plugin(prevent_default::init());
|
||||||
|
|
||||||
// Conditional compilation for macOS
|
// Conditional compilation for macOS
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
@@ -164,18 +166,19 @@ pub fn run() {
|
|||||||
extension::third_party::install::store::install_extension_from_store,
|
extension::third_party::install::store::install_extension_from_store,
|
||||||
extension::third_party::install::local_extension::install_local_extension,
|
extension::third_party::install::local_extension::install_local_extension,
|
||||||
extension::third_party::uninstall_extension,
|
extension::third_party::uninstall_extension,
|
||||||
|
extension::api::apis,
|
||||||
|
extension::api::fs::read_dir,
|
||||||
settings::set_allow_self_signature,
|
settings::set_allow_self_signature,
|
||||||
settings::get_allow_self_signature,
|
settings::get_allow_self_signature,
|
||||||
assistant::ask_ai,
|
assistant::ask_ai,
|
||||||
crate::common::document::open,
|
crate::common::document::open,
|
||||||
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
|
||||||
extension::built_in::file_search::config::get_file_system_config,
|
extension::built_in::file_search::config::get_file_system_config,
|
||||||
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
|
||||||
extension::built_in::file_search::config::set_file_system_config,
|
extension::built_in::file_search::config::set_file_system_config,
|
||||||
server::synthesize::synthesize,
|
server::synthesize::synthesize,
|
||||||
util::file::get_file_icon,
|
util::file::get_file_icon,
|
||||||
setup::backend_setup,
|
setup::backend_setup,
|
||||||
util::app_lang::update_app_lang,
|
util::app_lang::update_app_lang,
|
||||||
|
util::path::path_absolute,
|
||||||
])
|
])
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
@@ -264,6 +267,16 @@ async fn show_coco(app_handle: AppHandle) {
|
|||||||
|
|
||||||
let _ = window.show();
|
let _ = window.show();
|
||||||
let _ = window.unminimize();
|
let _ = window.unminimize();
|
||||||
|
|
||||||
|
// The Window Management (WM) extension (macOS-only) controls the
|
||||||
|
// frontmost window. Setting focus on macOS makes Coco the frontmost
|
||||||
|
// window, which means the WM extension would control Coco instead of other
|
||||||
|
// windows, which is not what we want.
|
||||||
|
//
|
||||||
|
// On Linux/Windows, however, setting focus is a necessity to ensure that
|
||||||
|
// users open Coco's window, then they can start typing, without needing
|
||||||
|
// to click on the window.
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
let _ = window.set_focus();
|
let _ = window.set_focus();
|
||||||
|
|
||||||
let _ = app_handle.emit("show-coco", ());
|
let _ = app_handle.emit("show-coco", ());
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use tauri::AppHandle;
|
|||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
fn request_access_token_url(request_id: &str) -> String {
|
fn request_access_token_url(request_id: &str) -> String {
|
||||||
// Remove the endpoint part and keep just the path for the request
|
// Remove the endpoint part and keep just the path for the request
|
||||||
format!("/auth/request_access_token?request_id={}", request_id)
|
format!("/auth/access_token?request_id={}", request_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
//! credits to: https://github.com/ayangweb/ayangweb-EcoPaste/blob/169323dbe6365ffe4abb64d867439ed2ea84c6d1/src-tauri/src/core/setup/mac.rs
|
//! credits to: https://github.com/ayangweb/ayangweb-EcoPaste/blob/169323dbe6365ffe4abb64d867439ed2ea84c6d1/src-tauri/src/core/setup/mac.rs
|
||||||
|
|
||||||
|
use crate::common::MAIN_WINDOW_LABEL;
|
||||||
|
use objc2_app_kit::NSNonactivatingPanelMask;
|
||||||
use tauri::{AppHandle, Emitter, EventTarget, WebviewWindow};
|
use tauri::{AppHandle, Emitter, EventTarget, WebviewWindow};
|
||||||
use tauri_nspanel::{WebviewWindowExt, cocoa::appkit::NSWindowCollectionBehavior, panel_delegate};
|
use tauri_nspanel::{WebviewWindowExt, cocoa::appkit::NSWindowCollectionBehavior, panel_delegate};
|
||||||
|
|
||||||
use crate::common::MAIN_WINDOW_LABEL;
|
|
||||||
|
|
||||||
#[allow(non_upper_case_globals)]
|
|
||||||
const NSWindowStyleMaskNonActivatingPanel: i32 = 1 << 7;
|
|
||||||
|
|
||||||
const WINDOW_FOCUS_EVENT: &str = "tauri://focus";
|
const WINDOW_FOCUS_EVENT: &str = "tauri://focus";
|
||||||
const WINDOW_BLUR_EVENT: &str = "tauri://blur";
|
const WINDOW_BLUR_EVENT: &str = "tauri://blur";
|
||||||
const WINDOW_MOVED_EVENT: &str = "tauri://move";
|
const WINDOW_MOVED_EVENT: &str = "tauri://move";
|
||||||
@@ -22,11 +19,17 @@ pub fn platform(
|
|||||||
// Convert ns_window to ns_panel
|
// Convert ns_window to ns_panel
|
||||||
let panel = main_window.to_panel().unwrap();
|
let panel = main_window.to_panel().unwrap();
|
||||||
|
|
||||||
// Make the window above the dock
|
|
||||||
panel.set_level(20);
|
|
||||||
|
|
||||||
// Do not steal focus from other windows
|
// Do not steal focus from other windows
|
||||||
panel.set_style_mask(NSWindowStyleMaskNonActivatingPanel);
|
//
|
||||||
|
// Cast is safe
|
||||||
|
panel.set_style_mask(NSNonactivatingPanelMask.0 as i32);
|
||||||
|
// Set its level to NSFloatingWindowLevel to ensure it appears in front of
|
||||||
|
// all normal-level windows
|
||||||
|
//
|
||||||
|
// NOTE: some Chinese input methods use a level between NSDockWindowLevel (20)
|
||||||
|
// and NSMainMenuWindowLevel (24), setting our level above NSDockWindowLevel
|
||||||
|
// would block their window
|
||||||
|
panel.set_floating_panel(true);
|
||||||
|
|
||||||
// Open the window in the active workspace and full screen
|
// Open the window in the active workspace and full screen
|
||||||
panel.set_collection_behaviour(
|
panel.set_collection_behaviour(
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ use crate::GLOBAL_TAURI_APP_HANDLE;
|
|||||||
use crate::autostart;
|
use crate::autostart;
|
||||||
use crate::common::register::SearchSourceRegistry;
|
use crate::common::register::SearchSourceRegistry;
|
||||||
use crate::util::app_lang::update_app_lang;
|
use crate::util::app_lang::update_app_lang;
|
||||||
use std::sync::OnceLock;
|
use std::sync::atomic::AtomicBool;
|
||||||
|
use std::sync::atomic::Ordering;
|
||||||
use tauri::{AppHandle, Manager, WebviewWindow};
|
use tauri::{AppHandle, Manager, WebviewWindow};
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
@@ -41,9 +42,11 @@ pub fn default(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Use this variable to track if tauri command `backend_setup()` gets called
|
/// Indicates if the setup job is completed.
|
||||||
/// by the frontend.
|
static BACKEND_SETUP_COMPLETED: AtomicBool = AtomicBool::new(false);
|
||||||
pub(super) static BACKEND_SETUP_FUNC_INVOKED: OnceLock<()> = OnceLock::new();
|
/// The function `backup_setup()` may be called concurrently, use this lock to
|
||||||
|
/// synchronize that only 1 async task can do the actual setup job.
|
||||||
|
static MUTEX_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
|
||||||
|
|
||||||
/// This function includes the setup job that has to be coordinated with the
|
/// This function includes the setup job that has to be coordinated with the
|
||||||
/// frontend, or the App will panic due to races[1]. The way we coordinate is to
|
/// frontend, or the App will panic due to races[1]. The way we coordinate is to
|
||||||
@@ -60,9 +63,17 @@ pub(super) static BACKEND_SETUP_FUNC_INVOKED: OnceLock<()> = OnceLock::new();
|
|||||||
/// called. If the frontend code invokes `list_extensions()` before `init_extension()`
|
/// called. If the frontend code invokes `list_extensions()` before `init_extension()`
|
||||||
/// gets executed, we get a panic.
|
/// gets executed, we get a panic.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[function_name::named]
|
|
||||||
pub(crate) async fn backend_setup(tauri_app_handle: AppHandle, app_lang: String) {
|
pub(crate) async fn backend_setup(tauri_app_handle: AppHandle, app_lang: String) {
|
||||||
if BACKEND_SETUP_FUNC_INVOKED.get().is_some() {
|
if BACKEND_SETUP_COMPLETED.load(Ordering::Relaxed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Race to let one async task do the setup job
|
||||||
|
let _guard = MUTEX_LOCK.lock().await;
|
||||||
|
|
||||||
|
// Re-check in case the current async task is not the first one that acquires
|
||||||
|
// the lock
|
||||||
|
if BACKEND_SETUP_COMPLETED.load(Ordering::Relaxed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,7 +88,16 @@ pub(crate) async fn backend_setup(tauri_app_handle: AppHandle, app_lang: String)
|
|||||||
// This has to be called before initializing extensions as doing that
|
// This has to be called before initializing extensions as doing that
|
||||||
// requires access to the shortcut store, which will be set by this
|
// requires access to the shortcut store, which will be set by this
|
||||||
// function.
|
// function.
|
||||||
crate::shortcut::enable_shortcut(&tauri_app_handle);
|
//
|
||||||
|
//
|
||||||
|
// Windows requires that hotkey setup has to be done on the main thread, or
|
||||||
|
// we will get error "ERROR_WINDOW_OF_OTHER_THREAD 1408 (0x580)"
|
||||||
|
let tauri_app_handle_clone = tauri_app_handle.clone();
|
||||||
|
tauri_app_handle
|
||||||
|
.run_on_main_thread(move || {
|
||||||
|
crate::shortcut::enable_shortcut(&tauri_app_handle_clone);
|
||||||
|
})
|
||||||
|
.expect("failed to run this closure on the main thread");
|
||||||
|
|
||||||
crate::init(&tauri_app_handle).await;
|
crate::init(&tauri_app_handle).await;
|
||||||
|
|
||||||
@@ -93,7 +113,5 @@ pub(crate) async fn backend_setup(tauri_app_handle: AppHandle, app_lang: String)
|
|||||||
update_app_lang(app_lang).await;
|
update_app_lang(app_lang).await;
|
||||||
|
|
||||||
// Invoked, now update the state
|
// Invoked, now update the state
|
||||||
BACKEND_SETUP_FUNC_INVOKED
|
BACKEND_SETUP_COMPLETED.store(true, Ordering::Relaxed);
|
||||||
.set(())
|
|
||||||
.unwrap_or_else(|_| panic!("tauri command {}() gets called twice!", function_name!()));
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ pub(crate) enum FileType {
|
|||||||
Unknown,
|
Unknown,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_file_type(path: &str) -> FileType {
|
fn get_file_type(path: &str) -> FileType {
|
||||||
let path = camino::Utf8Path::new(path);
|
let path = camino::Utf8Path::new(path);
|
||||||
|
|
||||||
// stat() is more precise than file extension, use it if possible.
|
// stat() is more precise than file extension, use it if possible.
|
||||||
@@ -167,8 +167,13 @@ fn type_to_icon(ty: FileType) -> &'static str {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
/// Synchronous version of `get_file_icon()`.
|
||||||
pub(crate) async fn get_file_icon(path: String) -> &'static str {
|
pub(crate) fn sync_get_file_icon(path: &str) -> &'static str {
|
||||||
let ty = get_file_type(path.as_str()).await;
|
let ty = get_file_type(path);
|
||||||
type_to_icon(ty)
|
type_to_icon(ty)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub(crate) async fn get_file_icon(path: String) -> &'static str {
|
||||||
|
sync_get_file_icon(&path)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
pub(crate) mod app_lang;
|
pub(crate) mod app_lang;
|
||||||
pub(crate) mod file;
|
pub(crate) mod file;
|
||||||
|
pub(crate) mod path;
|
||||||
pub(crate) mod platform;
|
pub(crate) mod platform;
|
||||||
|
pub(crate) mod prevent_default;
|
||||||
pub(crate) mod system_lang;
|
pub(crate) mod system_lang;
|
||||||
pub(crate) mod updater;
|
pub(crate) mod updater;
|
||||||
|
|
||||||
@@ -12,7 +14,7 @@ use tauri_plugin_shell::ShellExt;
|
|||||||
const XDG_CURRENT_DESKTOP: &str = "XDG_CURRENT_DESKTOP";
|
const XDG_CURRENT_DESKTOP: &str = "XDG_CURRENT_DESKTOP";
|
||||||
|
|
||||||
#[derive(Debug, PartialEq)]
|
#[derive(Debug, PartialEq)]
|
||||||
enum LinuxDesktopEnvironment {
|
pub(crate) enum LinuxDesktopEnvironment {
|
||||||
Gnome,
|
Gnome,
|
||||||
Kde,
|
Kde,
|
||||||
Unsupported { xdg_current_desktop: String },
|
Unsupported { xdg_current_desktop: String },
|
||||||
@@ -64,7 +66,7 @@ impl LinuxDesktopEnvironment {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// None means that it is likely that we do not have a desktop environment.
|
/// None means that it is likely that we do not have a desktop environment.
|
||||||
fn get_linux_desktop_environment() -> Option<LinuxDesktopEnvironment> {
|
pub(crate) fn get_linux_desktop_environment() -> Option<LinuxDesktopEnvironment> {
|
||||||
let de_os_str = std::env::var_os(XDG_CURRENT_DESKTOP)?;
|
let de_os_str = std::env::var_os(XDG_CURRENT_DESKTOP)?;
|
||||||
let de_str = de_os_str.into_string().unwrap_or_else(|_os_string| {
|
let de_str = de_os_str.into_string().unwrap_or_else(|_os_string| {
|
||||||
panic!("${} should be UTF-8 encoded", XDG_CURRENT_DESKTOP);
|
panic!("${} should be UTF-8 encoded", XDG_CURRENT_DESKTOP);
|
||||||
|
|||||||
12
src-tauri/src/util/path.rs
Normal file
12
src-tauri/src/util/path.rs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
#[tauri::command]
|
||||||
|
pub(crate) fn path_absolute(path: &str) -> String {
|
||||||
|
// We do not use std::path::absolute() because it does not clean ".."
|
||||||
|
// https://doc.rust-lang.org/stable/std/path/fn.absolute.html#platform-specific-behavior
|
||||||
|
use path_clean::clean;
|
||||||
|
|
||||||
|
let clean_path = clean(path);
|
||||||
|
clean_path
|
||||||
|
.into_os_string()
|
||||||
|
.into_string()
|
||||||
|
.expect("path should be UTF-8 encoded")
|
||||||
|
}
|
||||||
@@ -54,7 +54,6 @@ impl Platform {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Returns a set that contains all the platforms.
|
/// Returns a set that contains all the platforms.
|
||||||
#[cfg(test)] // currently, only used in tests
|
|
||||||
pub(crate) fn all() -> std::collections::HashSet<Self> {
|
pub(crate) fn all() -> std::collections::HashSet<Self> {
|
||||||
Platform::VARIANTS.into_iter().copied().collect()
|
Platform::VARIANTS.into_iter().copied().collect()
|
||||||
}
|
}
|
||||||
|
|||||||
13
src-tauri/src/util/prevent_default.rs
Normal file
13
src-tauri/src/util/prevent_default.rs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
pub fn init() -> tauri::plugin::TauriPlugin<tauri::Wry> {
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
{
|
||||||
|
use tauri_plugin_prevent_default::Flags;
|
||||||
|
|
||||||
|
tauri_plugin_prevent_default::Builder::new()
|
||||||
|
.with_flags(Flags::all().difference(Flags::CONTEXT_MENU))
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(debug_assertions))]
|
||||||
|
tauri_plugin_prevent_default::init()
|
||||||
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
use sys_locale::get_locale;
|
|
||||||
|
|
||||||
/// Helper function to get the system language.
|
/// Helper function to get the system language.
|
||||||
///
|
///
|
||||||
/// We cannot return `enum Lang` here because Coco has limited language support
|
/// We cannot return `enum Lang` here because Coco has limited language support
|
||||||
/// but the OS supports many more languages.
|
/// but the OS supports many more languages.
|
||||||
|
#[cfg(feature = "use_pizza_engine")]
|
||||||
pub(crate) fn get_system_lang() -> String {
|
pub(crate) fn get_system_lang() -> String {
|
||||||
|
use sys_locale::get_locale;
|
||||||
|
|
||||||
// fall back to English (general) when we cannot get the locale
|
// fall back to English (general) when we cannot get the locale
|
||||||
//
|
//
|
||||||
// We replace '-' with '_' in applications-rs, to make the locales match,
|
// We replace '-' with '_' in applications-rs, to make the locales match,
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
{
|
{
|
||||||
"label": "main",
|
"label": "main",
|
||||||
"title": "Coco AI",
|
"title": "Coco AI",
|
||||||
"url": "index.html/#/ui",
|
"url": "/ui",
|
||||||
"height": 590,
|
"height": 590,
|
||||||
"width": 680,
|
"width": 680,
|
||||||
"decorations": false,
|
"decorations": false,
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
{
|
{
|
||||||
"label": "settings",
|
"label": "settings",
|
||||||
"title": "Coco AI Settings",
|
"title": "Coco AI Settings",
|
||||||
"url": "index.html/#/ui/settings",
|
"url": "/ui/settings",
|
||||||
"width": 1000,
|
"width": 1000,
|
||||||
"minWidth": 1000,
|
"minWidth": 1000,
|
||||||
"height": 700,
|
"height": 700,
|
||||||
@@ -59,7 +59,7 @@
|
|||||||
{
|
{
|
||||||
"label": "check",
|
"label": "check",
|
||||||
"title": "Coco AI Update",
|
"title": "Coco AI Update",
|
||||||
"url": "index.html/#/ui/check",
|
"url": "/ui/check",
|
||||||
"width": 340,
|
"width": 340,
|
||||||
"minWidth": 340,
|
"minWidth": 340,
|
||||||
"height": 260,
|
"height": 260,
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import {
|
|||||||
MultiSourceQueryResponse,
|
MultiSourceQueryResponse,
|
||||||
} from "@/types/commands";
|
} from "@/types/commands";
|
||||||
import { useAppStore } from "@/stores/appStore";
|
import { useAppStore } from "@/stores/appStore";
|
||||||
import { useAuthStore } from "@/stores/authStore";
|
|
||||||
import {
|
import {
|
||||||
getCurrentWindowService,
|
getCurrentWindowService,
|
||||||
handleLogout,
|
handleLogout,
|
||||||
@@ -39,16 +38,9 @@ async function invokeWithErrorHandler<T>(
|
|||||||
command: string,
|
command: string,
|
||||||
args?: Record<string, any>
|
args?: Record<string, any>
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const isCurrentLogin = useAuthStore.getState().isCurrentLogin;
|
|
||||||
|
|
||||||
const service = await getCurrentWindowService();
|
const service = await getCurrentWindowService();
|
||||||
|
|
||||||
// Not logged in
|
if (!WHITELIST_SERVERS.includes(command) && !service?.profile) {
|
||||||
// console.log("isCurrentLogin", command, isCurrentLogin);
|
|
||||||
if (
|
|
||||||
!WHITELIST_SERVERS.includes(command) &&
|
|
||||||
(!isCurrentLogin || !service?.profile)
|
|
||||||
) {
|
|
||||||
console.error("This command requires authentication");
|
console.error("This command requires authentication");
|
||||||
throw new Error("This command requires authentication");
|
throw new Error("This command requires authentication");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useConnectStore } from "@/stores/connectStore";
|
|||||||
import { SETTINGS_WINDOW_LABEL } from "@/constants";
|
import { SETTINGS_WINDOW_LABEL } from "@/constants";
|
||||||
import platformAdapter from "@/utils/platformAdapter";
|
import platformAdapter from "@/utils/platformAdapter";
|
||||||
import { useAuthStore } from "@/stores/authStore";
|
import { useAuthStore } from "@/stores/authStore";
|
||||||
|
import { useExtensionsStore } from "@/stores/extensionsStore";
|
||||||
|
|
||||||
export async function getCurrentWindowService() {
|
export async function getCurrentWindowService() {
|
||||||
const currentService = useConnectStore.getState().currentService;
|
const currentService = useConnectStore.getState().currentService;
|
||||||
@@ -13,23 +14,42 @@ export async function getCurrentWindowService() {
|
|||||||
: currentService;
|
: currentService;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setCurrentWindowService(
|
export async function setCurrentWindowService(service: any, isAll?: boolean) {
|
||||||
service: any,
|
|
||||||
isAll?: boolean
|
|
||||||
) {
|
|
||||||
const { setCurrentService, setCloudSelectService } =
|
const { setCurrentService, setCloudSelectService } =
|
||||||
useConnectStore.getState();
|
useConnectStore.getState();
|
||||||
// all refresh logout
|
// all refresh logout
|
||||||
if (isAll) {
|
if (isAll) {
|
||||||
setCloudSelectService(service);
|
setCloudSelectService(service);
|
||||||
setCurrentService(service);
|
return setCurrentService(service);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
// current refresh
|
// current refresh
|
||||||
const windowLabel = await platformAdapter.getCurrentWindowLabel();
|
const windowLabel = await platformAdapter.getCurrentWindowLabel();
|
||||||
return windowLabel === SETTINGS_WINDOW_LABEL
|
|
||||||
? setCloudSelectService(service)
|
if (windowLabel === SETTINGS_WINDOW_LABEL) {
|
||||||
: setCurrentService(service);
|
const { currentService } = useConnectStore.getState();
|
||||||
|
const {
|
||||||
|
aiOverviewServer,
|
||||||
|
setAiOverviewServer,
|
||||||
|
quickAiAccessServer,
|
||||||
|
setQuickAiAccessServer,
|
||||||
|
} = useExtensionsStore.getState();
|
||||||
|
|
||||||
|
if (currentService?.id === service.id) {
|
||||||
|
setCurrentService(service);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (aiOverviewServer?.id === service.id) {
|
||||||
|
setAiOverviewServer(service);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (quickAiAccessServer?.id === service.id) {
|
||||||
|
setQuickAiAccessServer(service);
|
||||||
|
}
|
||||||
|
|
||||||
|
return setCloudSelectService(service);
|
||||||
|
}
|
||||||
|
|
||||||
|
return setCurrentService(service);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function handleLogout(serverId?: string) {
|
export async function handleLogout(serverId?: string) {
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export const AssistantFetcher = ({
|
|||||||
query?: string;
|
query?: string;
|
||||||
}) => {
|
}) => {
|
||||||
try {
|
try {
|
||||||
if (unrequitable()) {
|
if (await unrequitable()) {
|
||||||
return {
|
return {
|
||||||
total: 0,
|
total: 0,
|
||||||
list: [],
|
list: [],
|
||||||
|
|||||||
@@ -233,7 +233,7 @@ const ChatAI = memo(
|
|||||||
async (params: SendMessageParams) => {
|
async (params: SendMessageParams) => {
|
||||||
try {
|
try {
|
||||||
//console.log("init", curChatEnd, activeChat?._id);
|
//console.log("init", curChatEnd, activeChat?._id);
|
||||||
if (!isCurrentLogin) {
|
if (isTauri && !isCurrentLogin) {
|
||||||
addError("Please login to continue chatting");
|
addError("Please login to continue chatting");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -390,7 +390,7 @@ const ChatAI = memo(
|
|||||||
assistantIDs={assistantIDs}
|
assistantIDs={assistantIDs}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{isCurrentLogin || !isTauri ? (
|
{!isTauri || (isTauri && isCurrentLogin) ? (
|
||||||
<>
|
<>
|
||||||
<ChatContent
|
<ChatContent
|
||||||
activeChat={activeChat}
|
activeChat={activeChat}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import PinIcon from "@/icons/Pin";
|
|||||||
import WindowsFullIcon from "@/icons/WindowsFull";
|
import WindowsFullIcon from "@/icons/WindowsFull";
|
||||||
import { useAppStore } from "@/stores/appStore";
|
import { useAppStore } from "@/stores/appStore";
|
||||||
import type { Chat } from "@/types/chat";
|
import type { Chat } from "@/types/chat";
|
||||||
import platformAdapter from "@/utils/platformAdapter";
|
|
||||||
import VisibleKey from "../Common/VisibleKey";
|
import VisibleKey from "../Common/VisibleKey";
|
||||||
import { useShortcutsStore } from "@/stores/shortcutsStore";
|
import { useShortcutsStore } from "@/stores/shortcutsStore";
|
||||||
import { HISTORY_PANEL_ID } from "@/constants";
|
import { HISTORY_PANEL_ID } from "@/constants";
|
||||||
@@ -42,9 +41,9 @@ export function ChatHeader({
|
|||||||
|
|
||||||
const togglePin = async () => {
|
const togglePin = async () => {
|
||||||
try {
|
try {
|
||||||
const newPinned = !isPinned;
|
const { isPinned } = useAppStore.getState();
|
||||||
await platformAdapter.setAlwaysOnTop(newPinned);
|
|
||||||
setIsPinned(newPinned);
|
setIsPinned(!isPinned);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to toggle window pin state:", err);
|
console.error("Failed to toggle window pin state:", err);
|
||||||
setIsPinned(isPinned);
|
setIsPinned(isPinned);
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ const Splash = ({ assistantIDs = [], startPage }: SplashProps) => {
|
|||||||
|
|
||||||
let response: any;
|
let response: any;
|
||||||
if (isTauri) {
|
if (isTauri) {
|
||||||
if (unrequitable()) {
|
if (await unrequitable()) {
|
||||||
return setVisibleStartPage(false);
|
return setVisibleStartPage(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,6 +72,8 @@ const Splash = ({ assistantIDs = [], startPage }: SplashProps) => {
|
|||||||
setSettings(response);
|
setSettings(response);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
console.log("currentService", currentService);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getSettings();
|
getSettings();
|
||||||
fetchData();
|
fetchData();
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export const MessageActions = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSpeak = async () => {
|
const handleSpeak = async () => {
|
||||||
if (isDefaultServer()) {
|
if (await isDefaultServer()) {
|
||||||
return setSynthesizeItem({ id, content });
|
return setSynthesizeItem({ id, content });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { useEffect, useRef, useState, useCallback } from "react";
|
import { useEffect, useRef, useState, useCallback } from "react";
|
||||||
|
import { isEqual } from "lodash-es";
|
||||||
|
import { usePrevious } from "ahooks";
|
||||||
|
|
||||||
import { DataSourcesList } from "./DataSourcesList";
|
import { DataSourcesList } from "./DataSourcesList";
|
||||||
import { Sidebar } from "./Sidebar";
|
import { Sidebar } from "./Sidebar";
|
||||||
@@ -7,9 +9,9 @@ import { useAppStore } from "@/stores/appStore";
|
|||||||
import { useConnectStore } from "@/stores/connectStore";
|
import { useConnectStore } from "@/stores/connectStore";
|
||||||
import ServiceInfo from "./ServiceInfo";
|
import ServiceInfo from "./ServiceInfo";
|
||||||
import ServiceAuth from "./ServiceAuth";
|
import ServiceAuth from "./ServiceAuth";
|
||||||
import platformAdapter from "@/utils/platformAdapter";
|
|
||||||
import type { Server } from "@/types/server";
|
import type { Server } from "@/types/server";
|
||||||
import { useServers } from "@/hooks/useServers";
|
import { useServers } from "@/hooks/useServers";
|
||||||
|
import platformAdapter from "@/utils/platformAdapter";
|
||||||
|
|
||||||
export default function Cloud() {
|
export default function Cloud() {
|
||||||
const SidebarRef = useRef<{ refreshData: () => void }>(null);
|
const SidebarRef = useRef<{ refreshData: () => void }>(null);
|
||||||
@@ -24,6 +26,7 @@ export default function Cloud() {
|
|||||||
serverList,
|
serverList,
|
||||||
setServerList,
|
setServerList,
|
||||||
} = useConnectStore();
|
} = useConnectStore();
|
||||||
|
const prevServerList = usePrevious(serverList);
|
||||||
|
|
||||||
const [refreshLoading, setRefreshLoading] = useState(false);
|
const [refreshLoading, setRefreshLoading] = useState(false);
|
||||||
|
|
||||||
@@ -31,6 +34,8 @@ export default function Cloud() {
|
|||||||
|
|
||||||
// fetch the servers
|
// fetch the servers
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (isEqual(prevServerList, serverList)) return;
|
||||||
|
|
||||||
fetchServers();
|
fetchServers();
|
||||||
}, [serverList]);
|
}, [serverList]);
|
||||||
|
|
||||||
@@ -40,32 +45,37 @@ export default function Cloud() {
|
|||||||
}, [cloudSelectService?.id]);
|
}, [cloudSelectService?.id]);
|
||||||
|
|
||||||
const fetchServers = useCallback(async () => {
|
const fetchServers = useCallback(async () => {
|
||||||
let res = serverList;
|
let { serverList } = useConnectStore.getState();
|
||||||
|
|
||||||
if (errors.length > 0) {
|
if (errors.length > 0) {
|
||||||
res = res.map((item: Server) => {
|
serverList = serverList.map((item: Server) => {
|
||||||
if (item.id === cloudSelectService?.id) {
|
if (item.id === cloudSelectService?.id) {
|
||||||
item.health = {
|
return {
|
||||||
services: item.health?.services || {},
|
...item,
|
||||||
status: item.health?.status || "red",
|
health: {
|
||||||
|
services: item.health?.services || {},
|
||||||
|
status: item.health?.status || "red",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return item;
|
return item;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
setServerList(res);
|
|
||||||
|
|
||||||
if (res.length > 0) {
|
setServerList(serverList);
|
||||||
const matched = res.find((server: any) => {
|
|
||||||
|
if (serverList.length > 0) {
|
||||||
|
const matched = serverList.find((server: any) => {
|
||||||
return server.id === cloudSelectService?.id;
|
return server.id === cloudSelectService?.id;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (matched) {
|
if (matched) {
|
||||||
setCloudSelectService(matched);
|
setCloudSelectService(matched);
|
||||||
} else {
|
} else {
|
||||||
setCloudSelectService(res[res.length - 1]);
|
setCloudSelectService(serverList[serverList.length - 1]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [serverList, errors, cloudSelectService]);
|
}, [errors, cloudSelectService]);
|
||||||
|
|
||||||
const refreshClick = useCallback(
|
const refreshClick = useCallback(
|
||||||
async (id: string, callback?: () => void) => {
|
async (id: string, callback?: () => void) => {
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ const ServiceAuth = memo(
|
|||||||
// Generate the login URL with the current appUid
|
// Generate the login URL with the current appUid
|
||||||
const url = `${cloudSelectService?.auth_provider?.sso?.url}/?provider=${cloudSelectService?.id}&product=coco&request_id=${requestID}`;
|
const url = `${cloudSelectService?.auth_provider?.sso?.url}/?provider=${cloudSelectService?.id}&product=coco&request_id=${requestID}`;
|
||||||
|
|
||||||
console.log("Open SSO link, requestID:", ssoRequestID, url);
|
console.log("Open SSO link, requestID:", url);
|
||||||
|
|
||||||
// Open the URL in a browser
|
// Open the URL in a browser
|
||||||
OpenURLWithBrowser(url);
|
OpenURLWithBrowser(url);
|
||||||
|
|||||||
@@ -43,16 +43,17 @@ export default function Footer({ setIsPinnedWeb }: FooterProps) {
|
|||||||
|
|
||||||
const { fixedWindow, modifierKey } = useShortcutsStore();
|
const { fixedWindow, modifierKey } = useShortcutsStore();
|
||||||
|
|
||||||
const setWindowAlwaysOnTop = useCallback(async (isPinned: boolean) => {
|
|
||||||
setIsPinnedWeb?.(isPinned);
|
|
||||||
return platformAdapter.setAlwaysOnTop(isPinned);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const togglePin = async () => {
|
const togglePin = async () => {
|
||||||
try {
|
try {
|
||||||
const newPinned = !isPinned;
|
const { isTauri, isPinned } = useAppStore.getState();
|
||||||
await setWindowAlwaysOnTop(newPinned);
|
|
||||||
setIsPinned(newPinned);
|
const nextPinned = !isPinned;
|
||||||
|
|
||||||
|
if (!isTauri) {
|
||||||
|
setIsPinnedWeb?.(nextPinned);
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsPinned(nextPinned);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to toggle window pin state:", err);
|
console.error("Failed to toggle window pin state:", err);
|
||||||
setIsPinned(isPinned);
|
setIsPinned(isPinned);
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ const ContextMenu = ({ formatUrl }: ContextMenuProps) => {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const { id, url, category, type, payload } = selectedSearchContent;
|
const { id, url, category, type, payload, source } = selectedSearchContent;
|
||||||
const { query, result } = payload ?? {};
|
const { query, result } = payload ?? {};
|
||||||
|
|
||||||
if (category === "AI Overview") {
|
if (category === "AI Overview") {
|
||||||
@@ -177,6 +177,18 @@ const ContextMenu = ({ formatUrl }: ContextMenuProps) => {
|
|||||||
copyToClipboard(`${query.value} = ${result.value}`);
|
copyToClipboard(`${query.value} = ${result.value}`);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: t("search.contextMenu.openFileLocation"),
|
||||||
|
icon: <SquareArrowOutUpRight />,
|
||||||
|
keys: isMac ? ["⌘", "↩︎"] : ["Ctrl", "Enter"],
|
||||||
|
shortcut: isMac ? "meta.enter" : "ctrl.enter",
|
||||||
|
hide: source?.id !== "File Search",
|
||||||
|
clickEvent: async () => {
|
||||||
|
await platformAdapter.revealItemInDir(url);
|
||||||
|
|
||||||
|
platformAdapter.hideWindow();
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
}, [selectedSearchContent, selectedExtension]);
|
}, [selectedSearchContent, selectedExtension]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useKeyPress, useSize } from "ahooks";
|
import { useAsyncEffect, useKeyPress, useSize } from "ahooks";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
|
||||||
import AutoResizeTextarea from "./AutoResizeTextarea";
|
import AutoResizeTextarea from "./AutoResizeTextarea";
|
||||||
@@ -210,8 +210,8 @@ export default function ChatInput({
|
|||||||
const extraIconRef = useRef<HTMLDivElement>(null);
|
const extraIconRef = useRef<HTMLDivElement>(null);
|
||||||
const extraIconSize = useSize(extraIconRef);
|
const extraIconSize = useSize(extraIconRef);
|
||||||
|
|
||||||
useEffect(() => {
|
useAsyncEffect(async () => {
|
||||||
setVisibleAudioInput(isDefaultServer());
|
setVisibleAudioInput(await isDefaultServer());
|
||||||
}, [currentService]);
|
}, [currentService]);
|
||||||
|
|
||||||
const renderSearchIcon = () => (
|
const renderSearchIcon = () => (
|
||||||
|
|||||||
0
src/components/Search/ListDesktop.tsx
Normal file
0
src/components/Search/ListDesktop.tsx
Normal file
@@ -10,6 +10,7 @@ import AskAi from "./AskAi";
|
|||||||
import { useSearch } from "@/hooks/useSearch";
|
import { useSearch } from "@/hooks/useSearch";
|
||||||
import ExtensionStore from "./ExtensionStore";
|
import ExtensionStore from "./ExtensionStore";
|
||||||
import platformAdapter from "@/utils/platformAdapter";
|
import platformAdapter from "@/utils/platformAdapter";
|
||||||
|
import ViewExtension from "./ViewExtension"
|
||||||
|
|
||||||
const SearchResultsPanel = memo<{
|
const SearchResultsPanel = memo<{
|
||||||
input: string;
|
input: string;
|
||||||
@@ -46,7 +47,7 @@ const SearchResultsPanel = memo<{
|
|||||||
}
|
}
|
||||||
}, [input, isChatMode, performSearch, sourceData]);
|
}, [input, isChatMode, performSearch, sourceData]);
|
||||||
|
|
||||||
const { setSelectedAssistant, selectedSearchContent, visibleExtensionStore } =
|
const { setSelectedAssistant, selectedSearchContent, visibleExtensionStore, viewExtensionOpened } =
|
||||||
useSearchStore();
|
useSearchStore();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -60,6 +61,7 @@ const SearchResultsPanel = memo<{
|
|||||||
}
|
}
|
||||||
}, [selectedSearchContent]);
|
}, [selectedSearchContent]);
|
||||||
|
|
||||||
|
// update state
|
||||||
const handleOpenExtensionStore = useCallback(() => {
|
const handleOpenExtensionStore = useCallback(() => {
|
||||||
platformAdapter.showWindow();
|
platformAdapter.showWindow();
|
||||||
changeMode && changeMode(false);
|
changeMode && changeMode(false);
|
||||||
@@ -114,9 +116,16 @@ const SearchResultsPanel = memo<{
|
|||||||
handleOpenExtensionStore();
|
handleOpenExtensionStore();
|
||||||
}, [extensionId]);
|
}, [extensionId]);
|
||||||
|
|
||||||
|
// If state gets updated, render the UI
|
||||||
if (visibleExtensionStore) {
|
if (visibleExtensionStore) {
|
||||||
return <ExtensionStore extensionId={extensionId} />;
|
return <ExtensionStore extensionId={extensionId} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Render the view extension
|
||||||
|
if (viewExtensionOpened != null) {
|
||||||
|
return <ViewExtension />;
|
||||||
|
}
|
||||||
|
|
||||||
if (goAskAi) return <AskAi isChatMode={isChatMode} />;
|
if (goAskAi) return <AskAi isChatMode={isChatMode} />;
|
||||||
if (suggests.length === 0) return <NoResults />;
|
if (suggests.length === 0) return <NoResults />;
|
||||||
|
|
||||||
|
|||||||
252
src/components/Search/ViewExtension.tsx
Normal file
252
src/components/Search/ViewExtension.tsx
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
/*
|
||||||
|
* ViewExtension.tsx
|
||||||
|
*
|
||||||
|
* View that will be rendered when opening a View extension.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { useState, useEffect, useMemo } from "react";
|
||||||
|
import { ArrowLeft } from "lucide-react";
|
||||||
|
import { useSearchStore } from "@/stores/searchStore";
|
||||||
|
import { convertFileSrc, invoke } from "@tauri-apps/api/core";
|
||||||
|
import { ExtensionFileSystemPermission, FileSystemAccess } from "../Settings/Extensions";
|
||||||
|
|
||||||
|
const ViewExtension: React.FC = () => {
|
||||||
|
const { setViewExtensionOpened, viewExtensionOpened } = useSearchStore();
|
||||||
|
const [pagePath, setPagePath] = useState<string>("");
|
||||||
|
// Complete list of the backend APIs, grouped by their category.
|
||||||
|
const [apis, setApis] = useState<Map<string, string[]> | null>(null);
|
||||||
|
|
||||||
|
if (viewExtensionOpened == null) {
|
||||||
|
// When this view gets loaded, this state should not be NULL.
|
||||||
|
throw new Error(
|
||||||
|
"ViewExtension Error: viewExtensionOpened is null. This should not happen."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tauri/webview is not allowed to access local files directly,
|
||||||
|
// use convertFileSrc to work around the issue.
|
||||||
|
useEffect(() => {
|
||||||
|
const setupFileUrl = async () => {
|
||||||
|
// The check above ensures viewExtensionOpened is not null here.
|
||||||
|
const filePath = viewExtensionOpened[0];
|
||||||
|
if (filePath) {
|
||||||
|
setPagePath(convertFileSrc(filePath));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
setupFileUrl();
|
||||||
|
}, [viewExtensionOpened]);
|
||||||
|
|
||||||
|
// invoke `apis()` and set the state
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchApis = async () => {
|
||||||
|
try {
|
||||||
|
const availableApis = await invoke("apis") as Record<string, string[]>;
|
||||||
|
setApis(new Map(Object.entries(availableApis)));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch APIs:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchApis();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
setViewExtensionOpened(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// White list of the permission entries
|
||||||
|
const permission = viewExtensionOpened[1];
|
||||||
|
|
||||||
|
// apis is in format {"category": ["api1", "api2"]}, to make the permission check
|
||||||
|
// easier, reverse the map key values: {"api1": "category", "api2": "category"}
|
||||||
|
const reversedApis = useMemo(() => {
|
||||||
|
if (apis == null) {
|
||||||
|
return null; // Return null instead of throwing error when apis is not ready
|
||||||
|
}
|
||||||
|
|
||||||
|
const reversed = new Map<string, string>();
|
||||||
|
for (const [category, apiArray] of apis.entries()) {
|
||||||
|
for (const api of apiArray) {
|
||||||
|
reversed.set(api, category);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return reversed;
|
||||||
|
}, [apis]);
|
||||||
|
|
||||||
|
// Watch for events from iframes - only set up listener when reversedApis is ready
|
||||||
|
useEffect(() => {
|
||||||
|
// Don't set up the listener if reversedApis is not ready yet
|
||||||
|
if (!reversedApis) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const messageHandler = async (event: MessageEvent) => {
|
||||||
|
if (
|
||||||
|
event.source != null &&
|
||||||
|
typeof (event.source as any).postMessage === "function"
|
||||||
|
) {
|
||||||
|
const source = event.source as Window;
|
||||||
|
const { id, command } = event.data;
|
||||||
|
|
||||||
|
// 1. Check if the command exists
|
||||||
|
if (!reversedApis.has(command)) {
|
||||||
|
source.postMessage(
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
payload: null,
|
||||||
|
error: `Error: Command '${command}' is not a valid API.`,
|
||||||
|
},
|
||||||
|
event.origin
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Check if the extension has permission to call this API
|
||||||
|
const category = reversedApis.get(command)!;
|
||||||
|
var api = null;
|
||||||
|
if (permission == null) {
|
||||||
|
api = null
|
||||||
|
} else {
|
||||||
|
api = permission.api
|
||||||
|
};
|
||||||
|
if (!apiPermissionCheck(category, command, api)) {
|
||||||
|
source.postMessage(
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
payload: null,
|
||||||
|
error: `Error: permission denied, API ${command} is unavailable`,
|
||||||
|
},
|
||||||
|
event.origin
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var fs = null;
|
||||||
|
if (permission == null) {
|
||||||
|
fs = null
|
||||||
|
} else {
|
||||||
|
fs = permission.fs
|
||||||
|
};
|
||||||
|
if (!(await fsPermissionCheck(command, event.data, fs))) {
|
||||||
|
source.postMessage(
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
payload: null,
|
||||||
|
error: `Error: permission denied`,
|
||||||
|
},
|
||||||
|
event.origin
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command === "read_dir") {
|
||||||
|
const { path } = event.data;
|
||||||
|
try {
|
||||||
|
const fileNames: [String] = await invoke("read_dir", { path: path });
|
||||||
|
source.postMessage(
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
payload: fileNames,
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
event.origin
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
source.postMessage(
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
payload: null,
|
||||||
|
error: e,
|
||||||
|
},
|
||||||
|
event.origin
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("message", messageHandler);
|
||||||
|
console.info("Coco extension API listener is up");
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('message', messageHandler);
|
||||||
|
};
|
||||||
|
}, [reversedApis, permission]); // Add apiPermissions as dependency
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full w-full flex flex-col">
|
||||||
|
{/* Header with back button */}
|
||||||
|
<div className="flex items-center p-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<button
|
||||||
|
onClick={handleBack}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors text-gray-600 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={20} />
|
||||||
|
<span>Back to Search</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<div className="flex-1">
|
||||||
|
<iframe
|
||||||
|
src={pagePath}
|
||||||
|
className="w-full h-full border-0"
|
||||||
|
>
|
||||||
|
</iframe>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ViewExtension;
|
||||||
|
|
||||||
|
|
||||||
|
// Permission check function - TypeScript translation of Rust function
|
||||||
|
const apiPermissionCheck = (category: string, api: string, allowedApis: string[] | null): boolean => {
|
||||||
|
if (!allowedApis) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const qualifiedApi = `${category}:${api}`;
|
||||||
|
return allowedApis.some(a => a === qualifiedApi);
|
||||||
|
};
|
||||||
|
|
||||||
|
const extractFsAccessPattern = (command: string, requestPayload: any): [string, FileSystemAccess] => {
|
||||||
|
switch (command) {
|
||||||
|
case "read_dir": {
|
||||||
|
const { path } = requestPayload;
|
||||||
|
|
||||||
|
return [path, ["read"]];
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
throw new Error(`unknown command ${command}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fsPermissionCheck = async (command: string, requestPayload: any, fsPermission: ExtensionFileSystemPermission[] | null): Promise<boolean> => {
|
||||||
|
if (!fsPermission) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [ path, access ] = extractFsAccessPattern(command, requestPayload);
|
||||||
|
const clean_path = await invoke("path_absolute", { path: path });
|
||||||
|
|
||||||
|
// Walk through fsPermission array to find matching paths
|
||||||
|
for (const permission of fsPermission) {
|
||||||
|
if (permission.path === clean_path) {
|
||||||
|
// Check if all required access permissions are included in the permission's access array
|
||||||
|
const hasAllRequiredAccess = access.every(requiredAccess =>
|
||||||
|
permission.access.includes(requiredAccess)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasAllRequiredAccess) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ import { isLinux, isWin } from "@/utils/platform";
|
|||||||
import { appReducer, initialAppState } from "@/reducers/appReducer";
|
import { appReducer, initialAppState } from "@/reducers/appReducer";
|
||||||
import { useWindowEvents } from "@/hooks/useWindowEvents";
|
import { useWindowEvents } from "@/hooks/useWindowEvents";
|
||||||
import { useAppStore } from "@/stores/appStore";
|
import { useAppStore } from "@/stores/appStore";
|
||||||
|
import { useSearchStore } from "@/stores/searchStore";
|
||||||
import platformAdapter from "@/utils/platformAdapter";
|
import platformAdapter from "@/utils/platformAdapter";
|
||||||
import { useStartupStore } from "@/stores/startupStore";
|
import { useStartupStore } from "@/stores/startupStore";
|
||||||
import { useThemeStore } from "@/stores/themeStore";
|
import { useThemeStore } from "@/stores/themeStore";
|
||||||
@@ -104,6 +105,7 @@ function SearchChat({
|
|||||||
|
|
||||||
const [isWin10, setIsWin10] = useState(false);
|
const [isWin10, setIsWin10] = useState(false);
|
||||||
const blurred = useAppStore((state) => state.blurred);
|
const blurred = useAppStore((state) => state.blurred);
|
||||||
|
const { viewExtensionOpened } = useSearchStore();
|
||||||
|
|
||||||
useWindowEvents();
|
useWindowEvents();
|
||||||
|
|
||||||
@@ -287,41 +289,45 @@ function SearchChat({
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
{/* We don't want this inputbox when rendering View extensions */}
|
||||||
data-tauri-drag-region={isTauri}
|
{/* TODO: figure out a better way to disable this inputbox */}
|
||||||
className={`p-2 w-full flex justify-center transition-all duration-500 min-h-[82px] ${
|
{!viewExtensionOpened && (
|
||||||
isTransitioned ? "border-t" : "border-b"
|
<div
|
||||||
} border-[#E6E6E6] dark:border-[#272626]`}
|
data-tauri-drag-region={isTauri}
|
||||||
>
|
className={`p-2 w-full flex justify-center transition-all duration-500 min-h-[82px] ${
|
||||||
<InputBox
|
isTransitioned ? "border-t" : "border-b"
|
||||||
isChatMode={isChatMode}
|
} border-[#E6E6E6] dark:border-[#272626]`}
|
||||||
inputValue={input}
|
>
|
||||||
onSend={handleSendMessage}
|
<InputBox
|
||||||
disabled={isTyping}
|
isChatMode={isChatMode}
|
||||||
disabledChange={cancelChat}
|
inputValue={input}
|
||||||
changeMode={changeMode}
|
onSend={handleSendMessage}
|
||||||
changeInput={setInput}
|
disabled={isTyping}
|
||||||
isSearchActive={isSearchActive}
|
disabledChange={cancelChat}
|
||||||
setIsSearchActive={toggleSearchActive}
|
changeMode={changeMode}
|
||||||
isDeepThinkActive={isDeepThinkActive}
|
changeInput={setInput}
|
||||||
setIsDeepThinkActive={toggleDeepThinkActive}
|
isSearchActive={isSearchActive}
|
||||||
isMCPActive={isMCPActive}
|
setIsSearchActive={toggleSearchActive}
|
||||||
setIsMCPActive={toggleMCPActive}
|
isDeepThinkActive={isDeepThinkActive}
|
||||||
setupWindowFocusListener={setupWindowFocusListener}
|
setIsDeepThinkActive={toggleDeepThinkActive}
|
||||||
checkScreenPermission={checkScreenPermission}
|
isMCPActive={isMCPActive}
|
||||||
requestScreenPermission={requestScreenPermission}
|
setIsMCPActive={toggleMCPActive}
|
||||||
getScreenMonitors={getScreenMonitors}
|
setupWindowFocusListener={setupWindowFocusListener}
|
||||||
getScreenWindows={getScreenWindows}
|
checkScreenPermission={checkScreenPermission}
|
||||||
captureMonitorScreenshot={captureMonitorScreenshot}
|
requestScreenPermission={requestScreenPermission}
|
||||||
captureWindowScreenshot={captureWindowScreenshot}
|
getScreenMonitors={getScreenMonitors}
|
||||||
openFileDialog={openFileDialog}
|
getScreenWindows={getScreenWindows}
|
||||||
getFileMetadata={getFileMetadata}
|
captureMonitorScreenshot={captureMonitorScreenshot}
|
||||||
getFileIcon={getFileIcon}
|
captureWindowScreenshot={captureWindowScreenshot}
|
||||||
hasModules={hasModules}
|
openFileDialog={openFileDialog}
|
||||||
searchPlaceholder={searchPlaceholder}
|
getFileMetadata={getFileMetadata}
|
||||||
chatPlaceholder={chatPlaceholder}
|
getFileIcon={getFileIcon}
|
||||||
/>
|
hasModules={hasModules}
|
||||||
</div>
|
searchPlaceholder={searchPlaceholder}
|
||||||
|
chatPlaceholder={chatPlaceholder}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
data-tauri-drag-region={isTauri}
|
data-tauri-drag-region={isTauri}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { FC, useMemo, useState, useCallback } from "react";
|
import { FC, useMemo, useState, useCallback, useEffect } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { isArray } from "lodash-es";
|
import { isArray } from "lodash-es";
|
||||||
import { useAsyncEffect, useMount } from "ahooks";
|
import { useAsyncEffect, useMount } from "ahooks";
|
||||||
@@ -37,6 +37,13 @@ const SharedAi: FC<SharedAiProps> = (props) => {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [assistantSearchValue, setAssistantSearchValue] = useState("");
|
const [assistantSearchValue, setAssistantSearchValue] = useState("");
|
||||||
const [isLoadingAssistants, setIsLoadingAssistants] = useState(false);
|
const [isLoadingAssistants, setIsLoadingAssistants] = useState(false);
|
||||||
|
const { setCloudSelectService } = useConnectStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!server) return;
|
||||||
|
|
||||||
|
setCloudSelectService(server);
|
||||||
|
}, [server]);
|
||||||
|
|
||||||
const getEnabledServers = useCallback((servers: Server[]): Server[] => {
|
const getEnabledServers = useCallback((servers: Server[]): Server[] => {
|
||||||
if (!isArray(servers)) return [];
|
if (!isArray(servers)) return [];
|
||||||
@@ -56,7 +63,9 @@ const SharedAi: FC<SharedAiProps> = (props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (server) {
|
if (server) {
|
||||||
const matchServer = enabledServers.find((item) => item.id === server.id);
|
const matchServer = enabledServers.find(
|
||||||
|
(item) => item.id === server.id
|
||||||
|
);
|
||||||
if (matchServer) {
|
if (matchServer) {
|
||||||
setServer(matchServer);
|
setServer(matchServer);
|
||||||
return;
|
return;
|
||||||
@@ -65,7 +74,7 @@ const SharedAi: FC<SharedAiProps> = (props) => {
|
|||||||
|
|
||||||
setServer(enabledServers[0]);
|
setServer(enabledServers[0]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load servers:', error);
|
console.error("Failed to load servers:", error);
|
||||||
addError(`Failed to load servers: ${String(error)}`);
|
addError(`Failed to load servers: ${String(error)}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -86,7 +95,9 @@ const SharedAi: FC<SharedAiProps> = (props) => {
|
|||||||
query: assistantSearchValue,
|
query: assistantSearchValue,
|
||||||
});
|
});
|
||||||
|
|
||||||
const assistants: Assistant[] = data.list.map((item: any) => item._source);
|
const assistants: Assistant[] = data.list.map(
|
||||||
|
(item: any) => item._source
|
||||||
|
);
|
||||||
setAssistantList(assistants);
|
setAssistantList(assistants);
|
||||||
|
|
||||||
if (assistants.length === 0) {
|
if (assistants.length === 0) {
|
||||||
@@ -104,7 +115,7 @@ const SharedAi: FC<SharedAiProps> = (props) => {
|
|||||||
|
|
||||||
setAssistant(assistants[0]);
|
setAssistant(assistants[0]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch assistants:', error);
|
console.error("Failed to fetch assistants:", error);
|
||||||
addError(`Failed to fetch assistants: ${String(error)}`);
|
addError(`Failed to fetch assistants: ${String(error)}`);
|
||||||
setAssistantList([]);
|
setAssistantList([]);
|
||||||
setAssistant(undefined);
|
setAssistant(undefined);
|
||||||
@@ -181,7 +192,9 @@ const SharedAi: FC<SharedAiProps> = (props) => {
|
|||||||
searchable={searchable}
|
searchable={searchable}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
onSearch={onSearch}
|
onSearch={onSearch}
|
||||||
placeholder={isLoadingAssistants && searchable ? "Loading..." : undefined}
|
placeholder={
|
||||||
|
isLoadingAssistants && searchable ? "Loading..." : undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -45,6 +45,23 @@ interface ExtensionQuicklink {
|
|||||||
link: string;
|
link: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type FileSystemAccess = ("read" | "write")[];
|
||||||
|
|
||||||
|
export interface ExtensionFileSystemPermission {
|
||||||
|
path: string;
|
||||||
|
access: FileSystemAccess;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExtensionHttpPermission {
|
||||||
|
host: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExtensionPermission {
|
||||||
|
fs: ExtensionFileSystemPermission[] | null;
|
||||||
|
http: ExtensionHttpPermission[] | null;
|
||||||
|
api: string[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Extension {
|
export interface Extension {
|
||||||
id: ExtensionId;
|
id: ExtensionId;
|
||||||
type: ExtensionType;
|
type: ExtensionType;
|
||||||
@@ -60,8 +77,11 @@ export interface Extension {
|
|||||||
commands?: Extension[];
|
commands?: Extension[];
|
||||||
scripts?: Extension[];
|
scripts?: Extension[];
|
||||||
quicklinks?: Extension[];
|
quicklinks?: Extension[];
|
||||||
|
views?: Extension[];
|
||||||
settings: Record<string, unknown>;
|
settings: Record<string, unknown>;
|
||||||
developer?: string;
|
developer?: string;
|
||||||
|
page?: string;
|
||||||
|
permission?: ExtensionPermission;
|
||||||
}
|
}
|
||||||
|
|
||||||
type Category = LiteralUnion<
|
type Category = LiteralUnion<
|
||||||
|
|||||||
@@ -131,8 +131,11 @@ const UpdateApp = ({ isCheckPage }: UpdateAppProps) => {
|
|||||||
|
|
||||||
const { skipVersions, updateInfo } = useUpdateStore.getState();
|
const { skipVersions, updateInfo } = useUpdateStore.getState();
|
||||||
|
|
||||||
|
if(updateInfo?.version){
|
||||||
setSkipVersions([...skipVersions, updateInfo.version]);
|
setSkipVersions([...skipVersions, updateInfo.version]);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
isCheckPage ? hide_check() : setVisible(false);
|
isCheckPage ? hide_check() : setVisible(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -471,7 +471,7 @@ export function useChatActions(
|
|||||||
const getChatHistory = useCallback(async () => {
|
const getChatHistory = useCallback(async () => {
|
||||||
let response: any;
|
let response: any;
|
||||||
if (isTauri) {
|
if (isTauri) {
|
||||||
if (unrequitable()) {
|
if (await unrequitable()) {
|
||||||
return setChats([]);
|
return setChats([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -524,7 +524,7 @@ export function useChatActions(
|
|||||||
skipTaskbar: false,
|
skipTaskbar: false,
|
||||||
decorations: true,
|
decorations: true,
|
||||||
closable: true,
|
closable: true,
|
||||||
url: "index.html/#/ui/chat",
|
url: "/ui/chat",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -20,48 +20,44 @@ export interface DeepLinkHandler {
|
|||||||
|
|
||||||
export function useDeepLinkManager() {
|
export function useDeepLinkManager() {
|
||||||
const addError = useAppStore((state) => state.addError);
|
const addError = useAppStore((state) => state.addError);
|
||||||
const ssoRequestID = useAppStore((state) => state.ssoRequestID);
|
|
||||||
const cloudSelectService = useConnectStore(
|
|
||||||
(state) => state.cloudSelectService
|
|
||||||
);
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
// handle oauth callback
|
// handle oauth callback
|
||||||
const handleOAuthCallback = useCallback(
|
const handleOAuthCallback = useCallback(async (url: URL) => {
|
||||||
async (url: URL) => {
|
try {
|
||||||
try {
|
const reqId = url.searchParams.get("request_id");
|
||||||
const reqId = url.searchParams.get("request_id");
|
const code = url.searchParams.get("code");
|
||||||
const code = url.searchParams.get("code");
|
|
||||||
|
|
||||||
if (reqId !== ssoRequestID) {
|
const { ssoRequestID } = useAppStore.getState();
|
||||||
console.log("Request ID not matched, skip");
|
const { cloudSelectService } = useConnectStore.getState();
|
||||||
addError("Request ID not matched, skip");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const serverId = cloudSelectService?.id;
|
if (reqId !== ssoRequestID) {
|
||||||
if (!code || !serverId) {
|
console.log("Request ID not matched, skip");
|
||||||
addError("No authorization code received");
|
addError("Request ID not matched, skip");
|
||||||
return;
|
return;
|
||||||
}
|
|
||||||
|
|
||||||
console.log("Handling OAuth callback:", { code, serverId });
|
|
||||||
await platformAdapter.commands("handle_sso_callback", {
|
|
||||||
serverId: serverId,
|
|
||||||
requestId: ssoRequestID,
|
|
||||||
code: code,
|
|
||||||
});
|
|
||||||
|
|
||||||
// trigger oauth success event
|
|
||||||
platformAdapter.emitEvent("oauth_success", { serverId });
|
|
||||||
getCurrentWindow().setFocus();
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to parse OAuth callback URL:", err);
|
|
||||||
addError("Invalid OAuth callback URL format: " + err);
|
|
||||||
}
|
}
|
||||||
},
|
|
||||||
[ssoRequestID, cloudSelectService, addError]
|
const serverId = cloudSelectService?.id;
|
||||||
);
|
if (!code || !serverId) {
|
||||||
|
addError("No authorization code received");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Handling OAuth callback:", { code, serverId });
|
||||||
|
await platformAdapter.commands("handle_sso_callback", {
|
||||||
|
serverId: serverId,
|
||||||
|
requestId: ssoRequestID,
|
||||||
|
code: code,
|
||||||
|
});
|
||||||
|
|
||||||
|
// trigger oauth success event
|
||||||
|
platformAdapter.emitEvent("oauth_success", { serverId });
|
||||||
|
getCurrentWindow().setFocus();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to parse OAuth callback URL:", err);
|
||||||
|
addError("Invalid OAuth callback URL format: " + err);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
// handle install extension from store
|
// handle install extension from store
|
||||||
const handleInstallExtension = useCallback(async (url: URL) => {
|
const handleInstallExtension = useCallback(async (url: URL) => {
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export function useKeyboardNavigation({
|
|||||||
const modifierKey = useShortcutsStore((state) => {
|
const modifierKey = useShortcutsStore((state) => {
|
||||||
return state.modifierKey;
|
return state.modifierKey;
|
||||||
});
|
});
|
||||||
|
const { setSelectedSearchContent } = useSearchStore();
|
||||||
|
|
||||||
const getModifierKeyPressed = (event: KeyboardEvent) => {
|
const getModifierKeyPressed = (event: KeyboardEvent) => {
|
||||||
const metaKeyPressed = event.metaKey && modifierKey === "meta";
|
const metaKeyPressed = event.metaKey && modifierKey === "meta";
|
||||||
@@ -123,6 +124,8 @@ export function useKeyboardNavigation({
|
|||||||
|
|
||||||
const item = globalItemIndexMap[index];
|
const item = globalItemIndexMap[index];
|
||||||
|
|
||||||
|
setSelectedSearchContent(item);
|
||||||
|
|
||||||
platformAdapter.openSearchItem(item, formatUrl);
|
platformAdapter.openSearchItem(item, formatUrl);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -30,5 +30,5 @@ export const useIconfontScript = () => {
|
|||||||
// Coco Server Icons
|
// Coco Server Icons
|
||||||
useScript("https://at.alicdn.com/t/c/font_4878526_cykw3et0ezd.js");
|
useScript("https://at.alicdn.com/t/c/font_4878526_cykw3et0ezd.js");
|
||||||
// Coco App Icons
|
// Coco App Icons
|
||||||
useScript("https://at.alicdn.com/t/c/font_4934333_zclkkzo4fgo.js");
|
useScript("https://at.alicdn.com/t/c/font_4934333_0u00aavw7iob.js");
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ export const useSyncStore = () => {
|
|||||||
const setShowTooltip = useAppStore((state) => state.setShowTooltip);
|
const setShowTooltip = useAppStore((state) => state.setShowTooltip);
|
||||||
const setEndpoint = useAppStore((state) => state.setEndpoint);
|
const setEndpoint = useAppStore((state) => state.setEndpoint);
|
||||||
const setLanguage = useAppStore((state) => state.setLanguage);
|
const setLanguage = useAppStore((state) => state.setLanguage);
|
||||||
|
const { setCurrentService } = useConnectStore();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!resetFixedWindow) {
|
if (!resetFixedWindow) {
|
||||||
@@ -180,8 +181,12 @@ export const useSyncStore = () => {
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
platformAdapter.listenEvent("change-connect-store", ({ payload }) => {
|
platformAdapter.listenEvent("change-connect-store", ({ payload }) => {
|
||||||
const { connectionTimeout, querySourceTimeout, allowSelfSignature } =
|
const {
|
||||||
payload;
|
connectionTimeout,
|
||||||
|
querySourceTimeout,
|
||||||
|
allowSelfSignature,
|
||||||
|
currentService,
|
||||||
|
} = payload;
|
||||||
if (isNumber(connectionTimeout)) {
|
if (isNumber(connectionTimeout)) {
|
||||||
setConnectionTimeout(connectionTimeout);
|
setConnectionTimeout(connectionTimeout);
|
||||||
}
|
}
|
||||||
@@ -189,6 +194,7 @@ export const useSyncStore = () => {
|
|||||||
setQueryTimeout(querySourceTimeout);
|
setQueryTimeout(querySourceTimeout);
|
||||||
}
|
}
|
||||||
setAllowSelfSignature(allowSelfSignature);
|
setAllowSelfSignature(allowSelfSignature);
|
||||||
|
setCurrentService(currentService);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
platformAdapter.listenEvent("change-appearance-store", ({ payload }) => {
|
platformAdapter.listenEvent("change-appearance-store", ({ payload }) => {
|
||||||
|
|||||||
@@ -379,7 +379,8 @@
|
|||||||
"details": "Details",
|
"details": "Details",
|
||||||
"install": "Install",
|
"install": "Install",
|
||||||
"uninstall": "Uninstall",
|
"uninstall": "Uninstall",
|
||||||
"configureExtension": "Configure Extension"
|
"configureExtension": "Configure Extension",
|
||||||
|
"openFileLocation": "Open File Location"
|
||||||
},
|
},
|
||||||
"askCocoAi": {
|
"askCocoAi": {
|
||||||
"title": "{{0}} {{1}}",
|
"title": "{{0}} {{1}}",
|
||||||
|
|||||||
@@ -379,7 +379,8 @@
|
|||||||
"details": "详情",
|
"details": "详情",
|
||||||
"install": "安装",
|
"install": "安装",
|
||||||
"uninstall": "卸载",
|
"uninstall": "卸载",
|
||||||
"configureExtension": "配置扩展"
|
"configureExtension": "配置扩展",
|
||||||
|
"openFileLocation": "打开所在文件夹"
|
||||||
},
|
},
|
||||||
"askCocoAi": {
|
"askCocoAi": {
|
||||||
"title": "{{0}}{{1}}",
|
"title": "{{0}}{{1}}",
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ export default function StandaloneChat({}: StandaloneChatProps) {
|
|||||||
|
|
||||||
const getChatHistory = async () => {
|
const getChatHistory = async () => {
|
||||||
try {
|
try {
|
||||||
if (unrequitable()) {
|
if (await unrequitable()) {
|
||||||
return setChats([]);
|
return setChats([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -271,7 +271,11 @@ export default function StandaloneChat({}: StandaloneChatProps) {
|
|||||||
const handleDelete = async (id: string) => {
|
const handleDelete = async (id: string) => {
|
||||||
if (!currentService?.id) return;
|
if (!currentService?.id) return;
|
||||||
|
|
||||||
await platformAdapter.commands("delete_session_chat", currentService.id, id);
|
await platformAdapter.commands(
|
||||||
|
"delete_session_chat",
|
||||||
|
currentService.id,
|
||||||
|
id
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -6,11 +6,24 @@ import { useSyncStore } from "@/hooks/useSyncStore";
|
|||||||
import UpdateApp from "@/components/UpdateApp";
|
import UpdateApp from "@/components/UpdateApp";
|
||||||
import Synthesize from "@/components/Assistant/Synthesize";
|
import Synthesize from "@/components/Assistant/Synthesize";
|
||||||
import { useChatStore } from "@/stores/chatStore";
|
import { useChatStore } from "@/stores/chatStore";
|
||||||
|
import { useSearchStore } from "@/stores/searchStore";
|
||||||
|
import platformAdapter from "@/utils/platformAdapter";
|
||||||
|
|
||||||
function MainApp() {
|
function MainApp() {
|
||||||
const { setIsTauri } = useAppStore();
|
const { setIsTauri } = useAppStore();
|
||||||
|
const { setViewExtensionOpened } = useSearchStore();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsTauri(true);
|
setIsTauri(true);
|
||||||
|
|
||||||
|
// Set up the listener that listens for "open_view_extension" events
|
||||||
|
//
|
||||||
|
// Events will be sent when users try to open a View extension via hotkey,
|
||||||
|
// whose payload contains the needed information to load the View page.
|
||||||
|
platformAdapter.listenEvent("open_view_extension", async ({ payload: view_extension_page_and_permission } ) => {
|
||||||
|
await platformAdapter.showWindow();
|
||||||
|
setViewExtensionOpened(view_extension_page_and_permission);
|
||||||
|
})
|
||||||
}, []);
|
}, []);
|
||||||
const { synthesizeItem } = useChatStore();
|
const { synthesizeItem } = useChatStore();
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createHashRouter } from "react-router-dom";
|
import { createBrowserRouter } from "react-router-dom";
|
||||||
|
|
||||||
import Layout from "./layout";
|
import Layout from "./layout";
|
||||||
import ErrorPage from "@/pages/error/index";
|
import ErrorPage from "@/pages/error/index";
|
||||||
@@ -16,7 +16,7 @@ const routerOptions = {
|
|||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const router = createHashRouter(
|
export const router = createBrowserRouter(
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
path: "/",
|
path: "/",
|
||||||
|
|||||||
@@ -1,46 +1,22 @@
|
|||||||
import { useMount, useSessionStorageState } from "ahooks";
|
import { useMount } from "ahooks";
|
||||||
import { useEffect } from "react";
|
import { useState } from "react";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
import LayoutOutlet from "./outlet";
|
import LayoutOutlet from "./outlet";
|
||||||
import { useAppStore } from "@/stores/appStore";
|
import { useAppStore } from "@/stores/appStore";
|
||||||
import platformAdapter from "@/utils/platformAdapter";
|
|
||||||
import { CHAT_WINDOW_LABEL, MAIN_WINDOW_LABEL } from "@/constants";
|
|
||||||
|
|
||||||
const Layout = () => {
|
const Layout = () => {
|
||||||
const { language } = useAppStore();
|
const { language } = useAppStore();
|
||||||
const [ready, setReady] = useSessionStorageState("rust_ready", {
|
const [ready, setReady] = useState(false);
|
||||||
defaultValue: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
useMount(async () => {
|
useMount(async () => {
|
||||||
const label = await platformAdapter.getCurrentWindowLabel();
|
|
||||||
|
|
||||||
if (label === CHAT_WINDOW_LABEL) {
|
|
||||||
setReady(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ready || label !== MAIN_WINDOW_LABEL) return;
|
|
||||||
|
|
||||||
await invoke("backend_setup", {
|
await invoke("backend_setup", {
|
||||||
appLang: language,
|
appLang: language,
|
||||||
});
|
});
|
||||||
|
|
||||||
setReady(true);
|
setReady(true);
|
||||||
|
|
||||||
platformAdapter.emitEvent("rust_ready");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const unlisten = platformAdapter.listenEvent("rust_ready", () => {
|
|
||||||
setReady(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
unlisten.then((fn) => fn());
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return ready && <LayoutOutlet />;
|
return ready && <LayoutOutlet />;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,7 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { Outlet, useLocation } from "react-router-dom";
|
import { Outlet, useLocation } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import { useAsyncEffect, useEventListener, useMount } from "ahooks";
|
||||||
useAsyncEffect,
|
|
||||||
useEventListener,
|
|
||||||
useMount,
|
|
||||||
useTextSelection,
|
|
||||||
} from "ahooks";
|
|
||||||
import { isArray, isString } from "lodash-es";
|
import { isArray, isString } from "lodash-es";
|
||||||
|
|
||||||
import { useAppStore } from "@/stores/appStore";
|
import { useAppStore } from "@/stores/appStore";
|
||||||
@@ -95,15 +90,6 @@ export default function LayoutOutlet() {
|
|||||||
|
|
||||||
useSettingsWindow();
|
useSettingsWindow();
|
||||||
|
|
||||||
const { text: selectionText } = useTextSelection();
|
|
||||||
|
|
||||||
// Disable right-click for production environment
|
|
||||||
useEventListener("contextmenu", (event) => {
|
|
||||||
if (import.meta.env.DEV || selectionText) return;
|
|
||||||
|
|
||||||
event.preventDefault();
|
|
||||||
});
|
|
||||||
|
|
||||||
useModifierKeyPress();
|
useModifierKeyPress();
|
||||||
|
|
||||||
useEventListener("unhandledrejection", ({ reason }) => {
|
useEventListener("unhandledrejection", ({ reason }) => {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { SearchExtensionItem } from "@/components/Search/ExtensionStore";
|
import { SearchExtensionItem } from "@/components/Search/ExtensionStore";
|
||||||
|
import { ExtensionPermission } from "@/components/Settings/Extensions";
|
||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import { persist } from "zustand/middleware";
|
import { persist } from "zustand/middleware";
|
||||||
|
|
||||||
@@ -41,6 +42,13 @@ export type ISearchStore = {
|
|||||||
setUninstallingExtensions: (uninstallingExtensions: string[]) => void;
|
setUninstallingExtensions: (uninstallingExtensions: string[]) => void;
|
||||||
visibleExtensionDetail: boolean;
|
visibleExtensionDetail: boolean;
|
||||||
setVisibleExtensionDetail: (visibleExtensionDetail: boolean) => void;
|
setVisibleExtensionDetail: (visibleExtensionDetail: boolean) => void;
|
||||||
|
|
||||||
|
// When we open a View extension, we set this to a non-null value.
|
||||||
|
//
|
||||||
|
// The first array element is the path to the page that we should load, the
|
||||||
|
// second element is the permission that this extension requires.
|
||||||
|
viewExtensionOpened: [string, ExtensionPermission | null] | null;
|
||||||
|
setViewExtensionOpened: (showViewExtension: [string, ExtensionPermission | null] | null) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useSearchStore = create<ISearchStore>()(
|
export const useSearchStore = create<ISearchStore>()(
|
||||||
@@ -106,6 +114,10 @@ export const useSearchStore = create<ISearchStore>()(
|
|||||||
setVisibleExtensionDetail: (visibleExtensionDetail) => {
|
setVisibleExtensionDetail: (visibleExtensionDetail) => {
|
||||||
return set({ visibleExtensionDetail });
|
return set({ visibleExtensionDetail });
|
||||||
},
|
},
|
||||||
|
viewExtensionOpened: null,
|
||||||
|
setViewExtensionOpened: (viewExtensionOpened) => {
|
||||||
|
return set({ viewExtensionOpened });
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: "search-store",
|
name: "search-store",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { IStartupStore } from "@/stores/startupStore";
|
|||||||
import { AppTheme } from "@/types/index";
|
import { AppTheme } from "@/types/index";
|
||||||
import { SearchDocument } from "./search";
|
import { SearchDocument } from "./search";
|
||||||
import { IAppStore } from "@/stores/appStore";
|
import { IAppStore } from "@/stores/appStore";
|
||||||
|
import { ExtensionPermission } from "@/components/Settings/Extensions";
|
||||||
|
|
||||||
export interface EventPayloads {
|
export interface EventPayloads {
|
||||||
"theme-changed": string;
|
"theme-changed": string;
|
||||||
@@ -47,7 +48,7 @@ export interface EventPayloads {
|
|||||||
"check-update": any;
|
"check-update": any;
|
||||||
oauth_success: any;
|
oauth_success: any;
|
||||||
extension_install_success: any;
|
extension_install_success: any;
|
||||||
rust_ready: boolean;
|
"open_view_extension": [string, ExtensionPermission];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Window operation interface
|
// Window operation interface
|
||||||
|
|||||||
@@ -5,9 +5,8 @@ import { filesize as filesizeLib } from "filesize";
|
|||||||
import platformAdapter from "./platformAdapter";
|
import platformAdapter from "./platformAdapter";
|
||||||
import { useAppStore } from "@/stores/appStore";
|
import { useAppStore } from "@/stores/appStore";
|
||||||
import { DEFAULT_COCO_SERVER_ID, HISTORY_PANEL_ID } from "@/constants";
|
import { DEFAULT_COCO_SERVER_ID, HISTORY_PANEL_ID } from "@/constants";
|
||||||
import { useConnectStore } from "@/stores/connectStore";
|
|
||||||
import { useAuthStore } from "@/stores/authStore";
|
|
||||||
import { useChatStore } from "@/stores/chatStore";
|
import { useChatStore } from "@/stores/chatStore";
|
||||||
|
import { getCurrentWindowService } from "@/commands/windowService";
|
||||||
|
|
||||||
// 1
|
// 1
|
||||||
export async function copyToClipboard(text: string) {
|
export async function copyToClipboard(text: string) {
|
||||||
@@ -163,36 +162,28 @@ export const parseSearchQuery = (searchQuery: SearchQuery) => {
|
|||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const unrequitable = () => {
|
export const unrequitable = async () => {
|
||||||
const { isTauri } = useAppStore.getState();
|
const { isTauri } = useAppStore.getState();
|
||||||
const { currentService } = useConnectStore.getState();
|
const { id, available, enabled } = await getCurrentWindowService();
|
||||||
const { isCurrentLogin } = useAuthStore.getState();
|
|
||||||
const { id, available, enabled } = currentService ?? {};
|
|
||||||
|
|
||||||
const serviceAvailable = Boolean(
|
const serviceAvailable = Boolean(id && enabled && available);
|
||||||
id && enabled && available && isCurrentLogin
|
|
||||||
);
|
|
||||||
|
|
||||||
return isTauri && !serviceAvailable;
|
return isTauri && !serviceAvailable;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const isDefaultServer = (checkAvailability = true) => {
|
export const isDefaultServer = async (checkAvailability = true) => {
|
||||||
const { isTauri } = useAppStore.getState();
|
const { isTauri } = useAppStore.getState();
|
||||||
const { currentService } = useConnectStore.getState();
|
const { id, available, enabled } = await getCurrentWindowService();
|
||||||
const { isCurrentLogin } = useAuthStore.getState();
|
|
||||||
const { id, available, enabled } = currentService ?? {};
|
|
||||||
|
|
||||||
const isDefaultServer = currentService.id === DEFAULT_COCO_SERVER_ID;
|
const isDefault = id === DEFAULT_COCO_SERVER_ID;
|
||||||
|
|
||||||
const serviceAvailable = Boolean(
|
const serviceAvailable = Boolean(id && enabled && available);
|
||||||
id && enabled && available && isCurrentLogin
|
|
||||||
);
|
|
||||||
|
|
||||||
if (checkAvailability) {
|
if (checkAvailability) {
|
||||||
return isTauri && isDefaultServer && serviceAvailable;
|
return isTauri && isDefault && serviceAvailable;
|
||||||
}
|
}
|
||||||
|
|
||||||
return isTauri && isDefaultServer;
|
return isTauri && isDefault;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const filesize = (value: number, spacer?: string) => {
|
export const filesize = (value: number, spacer?: string) => {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import type { AppTheme } from "@/types/index";
|
|||||||
import { useAppearanceStore } from "@/stores/appearanceStore";
|
import { useAppearanceStore } from "@/stores/appearanceStore";
|
||||||
import { copyToClipboard, OpenURLWithBrowser } from ".";
|
import { copyToClipboard, OpenURLWithBrowser } from ".";
|
||||||
import { useAppStore } from "@/stores/appStore";
|
import { useAppStore } from "@/stores/appStore";
|
||||||
|
import { useSearchStore } from "@/stores/searchStore";
|
||||||
import { unrequitable } from "@/utils";
|
import { unrequitable } from "@/utils";
|
||||||
|
|
||||||
export interface TauriPlatformAdapter extends BasePlatformAdapter {
|
export interface TauriPlatformAdapter extends BasePlatformAdapter {
|
||||||
@@ -254,8 +255,9 @@ export const createTauriAdapter = (): TauriPlatformAdapter => {
|
|||||||
async openSearchItem(data) {
|
async openSearchItem(data) {
|
||||||
const { invoke } = await import("@tauri-apps/api/core");
|
const { invoke } = await import("@tauri-apps/api/core");
|
||||||
|
|
||||||
console.log("data", data);
|
console.log("openSearchItem", data);
|
||||||
|
|
||||||
|
// Extension store needs to be opened in a different way
|
||||||
if (data?.type === "AI Assistant" || data?.id === "Extension Store") {
|
if (data?.type === "AI Assistant" || data?.id === "Extension Store") {
|
||||||
const textarea = document.querySelector("#search-textarea");
|
const textarea = document.querySelector("#search-textarea");
|
||||||
|
|
||||||
@@ -275,6 +277,18 @@ export const createTauriAdapter = (): TauriPlatformAdapter => {
|
|||||||
return textarea.dispatchEvent(event);
|
return textarea.dispatchEvent(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// View extension should be handled separately as it needs frontend to open
|
||||||
|
// a page
|
||||||
|
const onOpened = data?.on_opened;
|
||||||
|
if (onOpened?.Extension?.ty?.View) {
|
||||||
|
const { setViewExtensionOpened } = useSearchStore.getState();
|
||||||
|
const viewData = onOpened.Extension.ty.View;
|
||||||
|
const extensionPermission = onOpened.Extension.permission;
|
||||||
|
|
||||||
|
setViewExtensionOpened([viewData.page, extensionPermission]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const hideCoco = () => {
|
const hideCoco = () => {
|
||||||
const isPinned = useAppStore.getState().isPinned;
|
const isPinned = useAppStore.getState().isPinned;
|
||||||
|
|
||||||
@@ -306,7 +320,7 @@ export const createTauriAdapter = (): TauriPlatformAdapter => {
|
|||||||
error,
|
error,
|
||||||
|
|
||||||
async searchMCPServers(serverId, queryParams) {
|
async searchMCPServers(serverId, queryParams) {
|
||||||
if (unrequitable()) {
|
if (await unrequitable()) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ export default defineConfig({
|
|||||||
|
|
||||||
const packageJson = {
|
const packageJson = {
|
||||||
name: "@infinilabs/search-chat",
|
name: "@infinilabs/search-chat",
|
||||||
version: "1.2.37",
|
version: "1.2.38",
|
||||||
main: "index.js",
|
main: "index.js",
|
||||||
module: "index.js",
|
module: "index.js",
|
||||||
type: "module",
|
type: "module",
|
||||||
|
|||||||
Reference in New Issue
Block a user