mirror of
https://github.com/infinilabs/coco-app.git
synced 2025-12-17 20:17:43 +01:00
Compare commits
75 Commits
v0.7.0
...
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 | ||
|
|
5dae5d1cc1 | ||
|
|
23372655ca | ||
|
|
f5b33af7f1 | ||
|
|
993da9a8ad | ||
|
|
93f1024230 | ||
|
|
7b5e528060 | ||
|
|
1d5ba3ab07 | ||
|
|
f93c527561 | ||
|
|
6065353ac9 | ||
|
|
783cb73b29 | ||
|
|
ee75f0d119 | ||
|
|
aaac874f2c | ||
|
|
cd9e454991 | ||
|
|
d0fc79238b | ||
|
|
3ed84c2318 | ||
|
|
bd039398ba | ||
|
|
568db6aba0 | ||
|
|
2eb10933e7 | ||
|
|
5c6cf18139 | ||
|
|
01c31d884a | ||
|
|
d48d4af7d2 | ||
|
|
876d14f9d9 | ||
|
|
a8e090c9be | ||
|
|
c30df6cee0 | ||
|
|
b833769c25 | ||
|
|
855fb2a168 | ||
|
|
d2735ec13b | ||
|
|
c40fc5818a | ||
|
|
a553ebd593 | ||
|
|
232166eb89 | ||
|
|
99144950d9 | ||
|
|
32d4f45144 | ||
|
|
6bc78b41ef | ||
|
|
cd54beee04 | ||
|
|
ee45d21bbe | ||
|
|
4709f8c660 | ||
|
|
4696aa1759 | ||
|
|
924fc09516 | ||
|
|
5a700662dd | ||
|
|
8f992bfa92 | ||
|
|
e7dd27c744 | ||
|
|
7914836c3e |
2
.env
2
.env
@@ -1,5 +1,3 @@
|
||||
COCO_SERVER_URL=http://localhost:9000 #https://coco.infini.cloud #http://localhost:9000
|
||||
|
||||
COCO_WEBSOCKET_URL=ws://localhost:9000/ws #wss://coco.infini.cloud/ws #ws://localhost:9000/ws
|
||||
|
||||
#TAURI_DEV_HOST=0.0.0.0
|
||||
34
.github/workflows/frontend-ci.yml
vendored
Normal file
34
.github/workflows/frontend-ci.yml
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
name: Frontend Code Check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
# Only run it when Frontend code changes
|
||||
paths:
|
||||
- 'src/**'
|
||||
|
||||
jobs:
|
||||
check:
|
||||
strategy:
|
||||
matrix:
|
||||
platform: [ubuntu-latest, windows-latest, macos-latest]
|
||||
|
||||
runs-on: ${{ matrix.platform }}
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
# No need to pass the version arg as it is specified by "packageManager" in package.json
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build frontend
|
||||
run: pnpm build
|
||||
12
.github/workflows/release.yml
vendored
12
.github/workflows/release.yml
vendored
@@ -104,7 +104,17 @@ jobs:
|
||||
if: startsWith(matrix.platform, 'ubuntu-22.04')
|
||||
run: |
|
||||
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
|
||||
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')
|
||||
run: |
|
||||
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
|
||||
working-directory: src-tauri
|
||||
|
||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -83,5 +83,6 @@
|
||||
"i18n-ally.keystyle": "nested",
|
||||
"editor.tabSize": 2,
|
||||
"editor.insertSpaces": true,
|
||||
"editor.detectIndentation": false
|
||||
"editor.detectIndentation": false,
|
||||
"i18n-ally.displayLanguage": "zh"
|
||||
}
|
||||
@@ -64,9 +64,9 @@ At Coco AI, we aim to streamline workplace collaboration by centralizing access
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js >= 18.12
|
||||
- Rust (latest stable)
|
||||
- pnpm (package manager)
|
||||
- [Node.js >= 18.12](https://nodejs.org/en/download/)
|
||||
- [Rust (latest stable)](https://www.rust-lang.org/tools/install)
|
||||
- [pnpm (package manager)](https://pnpm.io/installation)
|
||||
|
||||
### 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
|
||||
[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
|
||||
|
||||
|
||||
@@ -5,22 +5,81 @@ title: "Release Notes"
|
||||
|
||||
# Release Notes
|
||||
|
||||
Information about release notes of Coco Server is provided here.
|
||||
Information about release notes of Coco App is provided here.
|
||||
|
||||
## Latest (In development)
|
||||
### ❌ Breaking changes
|
||||
### 🚀 Features
|
||||
### 🐛 Bug fix
|
||||
### ✈️ Improvements
|
||||
|
||||
|
||||
## 0.8.0 (2025-09-28)
|
||||
|
||||
### ❌ Breaking changes
|
||||
|
||||
- chore: update request accesstoken api #866
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
- feat: enhance ui for skipped version #834
|
||||
- feat: support installing local extensions #749
|
||||
- feat: support sending files in chat messages #764
|
||||
- feat: sub extension can set 'platforms' now #847
|
||||
- feat: add extension uninstall option in settings #855
|
||||
- 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: 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
|
||||
|
||||
- fix: fix issue with update check failure #833
|
||||
- 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
|
||||
|
||||
- refactor: calling service related interfaces #831
|
||||
- refactor: split query_coco_fusion() #836
|
||||
- chore: web component loading font icon #838
|
||||
- chore: delete unused code files and dependencies #841
|
||||
- chore: ignore tauri::AppHandle's generic argument R #845
|
||||
- refactor: check Extension/plugin.json from all sources #846
|
||||
- refactor: pinning window won't set CanJoinAllSpaces on macOS #854
|
||||
- build: web component build error #858
|
||||
- refactor: coordinate third-party extension operations using lock #867
|
||||
- refactor: index iOS apps and macOS apps that store icon in Assets.car #872
|
||||
- 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)
|
||||
|
||||
### ❌ Breaking changes
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
### 🐛 Bug fix
|
||||
|
||||
- fix: correct enter key behavior #828
|
||||
|
||||
### ✈️ Improvements
|
||||
|
||||
- chore: web component add notification component #825
|
||||
- refactor: collection behavior defaults to `MoveToActiveSpace`, and only use `CanJoinAllSpaces` when window is pinned #829
|
||||
|
||||
## 0.7.0 (2025-07-25)
|
||||
|
||||
|
||||
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",
|
||||
"private": true,
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -26,12 +26,11 @@
|
||||
"@tauri-apps/plugin-global-shortcut": "~2.0.0",
|
||||
"@tauri-apps/plugin-http": "~2.0.2",
|
||||
"@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-process": "^2.2.1",
|
||||
"@tauri-apps/plugin-shell": "^2.2.1",
|
||||
"@tauri-apps/plugin-updater": "github:infinilabs/tauri-plugin-updater#v2",
|
||||
"@tauri-apps/plugin-websocket": "~2.3.0",
|
||||
"@tauri-apps/plugin-window": "2.0.0-alpha.1",
|
||||
"@wavesurfer/react": "^1.0.11",
|
||||
"ahooks": "^3.8.4",
|
||||
@@ -94,4 +93,4 @@
|
||||
"vite": "^5.4.19"
|
||||
},
|
||||
"packageManager": "pnpm@10.11.0+sha512.6540583f41cc5f628eb3d9773ecee802f4f9ef9923cc45b69890fb47991d4b092964694ec3a4f738a420c918a333062c8b925d312f42e4f0c263eb603551f977"
|
||||
}
|
||||
}
|
||||
|
||||
73
pnpm-lock.yaml
generated
73
pnpm-lock.yaml
generated
@@ -33,8 +33,8 @@ importers:
|
||||
specifier: ~2.4.0
|
||||
version: 2.4.0
|
||||
'@tauri-apps/plugin-opener':
|
||||
specifier: ^2.2.7
|
||||
version: 2.2.7
|
||||
specifier: ^2.5.0
|
||||
version: 2.5.0
|
||||
'@tauri-apps/plugin-os':
|
||||
specifier: ^2.2.1
|
||||
version: 2.2.1
|
||||
@@ -47,9 +47,6 @@ importers:
|
||||
'@tauri-apps/plugin-updater':
|
||||
specifier: github:infinilabs/tauri-plugin-updater#v2
|
||||
version: https://codeload.github.com/infinilabs/tauri-plugin-updater/tar.gz/358e689c65e9943b53eff50bcb9dfd5b1cfc4072
|
||||
'@tauri-apps/plugin-websocket':
|
||||
specifier: ~2.3.0
|
||||
version: 2.3.0
|
||||
'@tauri-apps/plugin-window':
|
||||
specifier: 2.0.0-alpha.1
|
||||
version: 2.0.0-alpha.1
|
||||
@@ -793,6 +790,9 @@ packages:
|
||||
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
'@jridgewell/gen-mapping@0.3.13':
|
||||
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
|
||||
|
||||
'@jridgewell/gen-mapping@0.3.8':
|
||||
resolution: {integrity: sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
@@ -805,15 +805,21 @@ packages:
|
||||
resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
|
||||
'@jridgewell/source-map@0.3.6':
|
||||
resolution: {integrity: sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==}
|
||||
'@jridgewell/source-map@0.3.11':
|
||||
resolution: {integrity: sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==}
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.5.0':
|
||||
resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==}
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.5.5':
|
||||
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
|
||||
|
||||
'@jridgewell/trace-mapping@0.3.25':
|
||||
resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==}
|
||||
|
||||
'@jridgewell/trace-mapping@0.3.31':
|
||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
||||
|
||||
'@mermaid-js/parser@0.4.0':
|
||||
resolution: {integrity: sha512-wla8XOWvQAwuqy+gxiZqY+c7FokraOTHRWMsbB4AgRx9Sy7zKslNyejy7E+a77qHfey5GXw/ik3IXv/NHMJgaA==}
|
||||
|
||||
@@ -1156,6 +1162,9 @@ packages:
|
||||
'@tauri-apps/api@2.5.0':
|
||||
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':
|
||||
resolution: {integrity: sha512-VuVAeTFq86dfpoBDNYAdtQVLbP0+2EKCHIIhkaxjeoPARR0sLpFHz2zs0PcFU76e+KAaxtEtAJAXGNUc8E1PzQ==}
|
||||
engines: {node: '>= 10'}
|
||||
@@ -1245,8 +1254,8 @@ packages:
|
||||
'@tauri-apps/plugin-log@2.4.0':
|
||||
resolution: {integrity: sha512-j7yrDtLNmayCBOO2esl3aZv9jSXy2an8MDLry3Ys9ZXerwUg35n1Y2uD8HoCR+8Ng/EUgx215+qOUfJasjYrHw==}
|
||||
|
||||
'@tauri-apps/plugin-opener@2.2.7':
|
||||
resolution: {integrity: sha512-uduEyvOdjpPOEeDRrhwlCspG/f9EQalHumWBtLBnp3fRp++fKGLqDOyUhSIn7PzX45b/rKep//ZQSAQoIxobLA==}
|
||||
'@tauri-apps/plugin-opener@2.5.0':
|
||||
resolution: {integrity: sha512-B0LShOYae4CZjN8leiNDbnfjSrTwoZakqKaWpfoH6nXiJwt6Rgj6RnVIffG3DoJiKsffRhMkjmBV9VeilSb4TA==}
|
||||
|
||||
'@tauri-apps/plugin-os@2.2.1':
|
||||
resolution: {integrity: sha512-cNYpNri2CCc6BaNeB6G/mOtLvg8dFyFQyCUdf2y0K8PIAKGEWdEcu8DECkydU2B+oj4OJihDPD2de5K6cbVl9A==}
|
||||
@@ -1261,9 +1270,6 @@ packages:
|
||||
resolution: {tarball: https://codeload.github.com/infinilabs/tauri-plugin-updater/tar.gz/358e689c65e9943b53eff50bcb9dfd5b1cfc4072}
|
||||
version: 2.7.1
|
||||
|
||||
'@tauri-apps/plugin-websocket@2.3.0':
|
||||
resolution: {integrity: sha512-eAwRGe3tnqDeQYE0wq4g1PUKbam9tYvlC4uP/au12Y/z7MP4lrS4ylv+aoZ5Ly+hTlBdi7hDkhHomwF/UeBesA==}
|
||||
|
||||
'@tauri-apps/plugin-window@2.0.0-alpha.1':
|
||||
resolution: {integrity: sha512-dFOAgal/3Txz3SQ+LNQq0AK1EPC+acdaFlwPVB/6KXUZYmaFleIlzgxDVoJCQ+/xOhxvYrdQaFLefh0I/Kldbg==}
|
||||
|
||||
@@ -1470,6 +1476,11 @@ packages:
|
||||
engines: {node: '>=0.4.0'}
|
||||
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:
|
||||
resolution: {integrity: sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==}
|
||||
engines: {node: '>= 14'}
|
||||
@@ -4236,6 +4247,12 @@ snapshots:
|
||||
wrap-ansi: 8.1.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':
|
||||
dependencies:
|
||||
'@jridgewell/set-array': 1.2.1
|
||||
@@ -4246,19 +4263,28 @@ snapshots:
|
||||
|
||||
'@jridgewell/set-array@1.2.1': {}
|
||||
|
||||
'@jridgewell/source-map@0.3.6':
|
||||
'@jridgewell/source-map@0.3.11':
|
||||
dependencies:
|
||||
'@jridgewell/gen-mapping': 0.3.8
|
||||
'@jridgewell/trace-mapping': 0.3.25
|
||||
'@jridgewell/gen-mapping': 0.3.13
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
optional: true
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.5.0': {}
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.5.5':
|
||||
optional: true
|
||||
|
||||
'@jridgewell/trace-mapping@0.3.25':
|
||||
dependencies:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@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':
|
||||
dependencies:
|
||||
langium: 3.3.1
|
||||
@@ -4552,6 +4578,8 @@ snapshots:
|
||||
|
||||
'@tauri-apps/api@2.5.0': {}
|
||||
|
||||
'@tauri-apps/api@2.8.0': {}
|
||||
|
||||
'@tauri-apps/cli-darwin-arm64@2.5.0':
|
||||
optional: true
|
||||
|
||||
@@ -4623,9 +4651,9 @@ snapshots:
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.5.0
|
||||
|
||||
'@tauri-apps/plugin-opener@2.2.7':
|
||||
'@tauri-apps/plugin-opener@2.5.0':
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.5.0
|
||||
'@tauri-apps/api': 2.8.0
|
||||
|
||||
'@tauri-apps/plugin-os@2.2.1':
|
||||
dependencies:
|
||||
@@ -4643,10 +4671,6 @@ snapshots:
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.5.0
|
||||
|
||||
'@tauri-apps/plugin-websocket@2.3.0':
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.5.0
|
||||
|
||||
'@tauri-apps/plugin-window@2.0.0-alpha.1':
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.0.0-alpha.6
|
||||
@@ -4886,6 +4910,9 @@ snapshots:
|
||||
|
||||
acorn@8.14.1: {}
|
||||
|
||||
acorn@8.15.0:
|
||||
optional: true
|
||||
|
||||
agent-base@7.1.3: {}
|
||||
|
||||
ahooks@3.8.4(react@18.3.1):
|
||||
@@ -7242,8 +7269,8 @@ snapshots:
|
||||
|
||||
terser@5.40.0:
|
||||
dependencies:
|
||||
'@jridgewell/source-map': 0.3.6
|
||||
acorn: 8.14.1
|
||||
'@jridgewell/source-map': 0.3.11
|
||||
acorn: 8.15.0
|
||||
commander: 2.20.3
|
||||
source-map-support: 0.5.21
|
||||
optional: true
|
||||
|
||||
2890
src-tauri/Cargo.lock
generated
2890
src-tauri/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "coco"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
description = "Search, connect, collaborate – all in one place."
|
||||
authors = ["INFINI Labs"]
|
||||
edition = "2024"
|
||||
@@ -15,6 +15,7 @@ crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = ["default"] }
|
||||
cfg-if = "1.0.1"
|
||||
|
||||
[features]
|
||||
default = ["desktop"]
|
||||
@@ -51,7 +52,6 @@ serde = { version = "1", features = ["derive"] }
|
||||
# see: https://docs.rs/serde_json/latest/serde_json/struct.Number.html#method.from_u128
|
||||
serde_json = { version = "1", features = ["arbitrary_precision", "preserve_order"] }
|
||||
tauri-plugin-http = "2"
|
||||
tauri-plugin-websocket = "2"
|
||||
tauri-plugin-deep-link = "2.0.0"
|
||||
tauri-plugin-store = "2.2.0"
|
||||
tauri-plugin-os = "2"
|
||||
@@ -62,7 +62,7 @@ tauri-plugin-drag = "2"
|
||||
tauri-plugin-macos-permissions = "2"
|
||||
tauri-plugin-fs-pro = "2"
|
||||
tauri-plugin-screenshots = "2"
|
||||
applications = { git = "https://github.com/infinilabs/applications-rs", rev = "31b0c030a0f3bc82275fe12debe526153978671d" }
|
||||
applications = { git = "https://github.com/infinilabs/applications-rs", rev = "b5fac4034a40d42e72f727f1aa1cc1f19fe86653" }
|
||||
tokio-native-tls = "0.3" # For wss connections
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tokio-tungstenite = { version = "0.20", features = ["native-tls"] }
|
||||
@@ -104,14 +104,42 @@ zip = "4.0.0"
|
||||
url = "2.5.2"
|
||||
camino = "1.1.10"
|
||||
tokio-stream = { version = "0.1.17", features = ["io-util"] }
|
||||
cfg-if = "1.0.1"
|
||||
sysinfo = "0.35.2"
|
||||
indexmap = { version = "2.10.0", features = ["serde"] }
|
||||
strum = { version = "0.27.2", features = ["derive"] }
|
||||
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]
|
||||
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]
|
||||
tauri-plugin-single-instance = { version = "2.0.0", features = ["deep-link"] }
|
||||
serde = { version = "1.0.219", features = ["derive"], optional = true }
|
||||
|
||||
|
||||
[profile.dev]
|
||||
incremental = true # Compile your binary in smaller steps.
|
||||
@@ -132,4 +160,8 @@ semver = { version = "1", features = ["serde"] }
|
||||
|
||||
[target."cfg(target_os = \"windows\")".dependencies]
|
||||
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`
|
||||
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.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,9 +37,6 @@
|
||||
"http:allow-fetch-cancel",
|
||||
"http:allow-fetch-read-body",
|
||||
"http:allow-fetch-send",
|
||||
"websocket:default",
|
||||
"websocket:allow-connect",
|
||||
"websocket:allow-send",
|
||||
"autostart:allow-enable",
|
||||
"autostart:allow-disable",
|
||||
"autostart:allow-is-enabled",
|
||||
@@ -72,6 +69,7 @@
|
||||
"updater:default",
|
||||
"windows-version:default",
|
||||
"log:default",
|
||||
"opener:default"
|
||||
"opener:default",
|
||||
"core:window:allow-unminimize"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::common::assistant::ChatRequestMessage;
|
||||
use crate::common::http::{GetResponse, convert_query_params_to_strings};
|
||||
use crate::common::http::convert_query_params_to_strings;
|
||||
use crate::common::register::SearchSourceRegistry;
|
||||
use crate::server::http_client::HttpClient;
|
||||
use crate::{common, server::servers::COCO_SERVERS};
|
||||
@@ -9,12 +9,12 @@ use futures_util::TryStreamExt;
|
||||
use http::Method;
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use tauri::{AppHandle, Emitter, Manager, Runtime};
|
||||
use tauri::{AppHandle, Emitter, Manager};
|
||||
use tokio::io::AsyncBufReadExt;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn chat_history<R: Runtime>(
|
||||
_app_handle: AppHandle<R>,
|
||||
pub async fn chat_history(
|
||||
_app_handle: AppHandle,
|
||||
server_id: String,
|
||||
from: u32,
|
||||
size: u32,
|
||||
@@ -43,8 +43,8 @@ pub async fn chat_history<R: Runtime>(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn session_chat_history<R: Runtime>(
|
||||
_app_handle: AppHandle<R>,
|
||||
pub async fn session_chat_history(
|
||||
_app_handle: AppHandle,
|
||||
server_id: String,
|
||||
session_id: String,
|
||||
from: u32,
|
||||
@@ -66,8 +66,8 @@ pub async fn session_chat_history<R: Runtime>(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn open_session_chat<R: Runtime>(
|
||||
_app_handle: AppHandle<R>,
|
||||
pub async fn open_session_chat(
|
||||
_app_handle: AppHandle,
|
||||
server_id: String,
|
||||
session_id: String,
|
||||
) -> Result<String, String> {
|
||||
@@ -81,8 +81,8 @@ pub async fn open_session_chat<R: Runtime>(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn close_session_chat<R: Runtime>(
|
||||
_app_handle: AppHandle<R>,
|
||||
pub async fn close_session_chat(
|
||||
_app_handle: AppHandle,
|
||||
server_id: String,
|
||||
session_id: String,
|
||||
) -> Result<String, String> {
|
||||
@@ -95,8 +95,8 @@ pub async fn close_session_chat<R: Runtime>(
|
||||
common::http::get_response_body_text(response).await
|
||||
}
|
||||
#[tauri::command]
|
||||
pub async fn cancel_session_chat<R: Runtime>(
|
||||
_app_handle: AppHandle<R>,
|
||||
pub async fn cancel_session_chat(
|
||||
_app_handle: AppHandle,
|
||||
server_id: String,
|
||||
session_id: String,
|
||||
query_params: Option<HashMap<String, Value>>,
|
||||
@@ -112,72 +112,37 @@ pub async fn cancel_session_chat<R: Runtime>(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn new_chat<R: Runtime>(
|
||||
_app_handle: AppHandle<R>,
|
||||
pub async fn chat_create(
|
||||
app_handle: AppHandle,
|
||||
server_id: String,
|
||||
websocket_id: String,
|
||||
message: String,
|
||||
query_params: Option<HashMap<String, Value>>,
|
||||
) -> Result<GetResponse, String> {
|
||||
let body = if !message.is_empty() {
|
||||
let message = ChatRequestMessage {
|
||||
message: Some(message),
|
||||
};
|
||||
Some(
|
||||
serde_json::to_string(&message)
|
||||
.map_err(|e| format!("Failed to serialize message: {}", e))?
|
||||
.into(),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let mut headers = HashMap::new();
|
||||
headers.insert("WEBSOCKET-SESSION-ID".to_string(), websocket_id.into());
|
||||
|
||||
let response = HttpClient::advanced_post(
|
||||
&server_id,
|
||||
"/chat/_new",
|
||||
Some(headers),
|
||||
convert_query_params_to_strings(query_params),
|
||||
body,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Error sending message: {}", e))?;
|
||||
|
||||
let body_text = common::http::get_response_body_text(response).await?;
|
||||
|
||||
log::debug!("New chat response: {}", &body_text);
|
||||
|
||||
let chat_response: GetResponse = serde_json::from_str(&body_text)
|
||||
.map_err(|e| format!("Failed to parse response JSON: {}", e))?;
|
||||
|
||||
if chat_response.result != "created" {
|
||||
return Err(format!("Unexpected result: {}", chat_response.result));
|
||||
}
|
||||
|
||||
Ok(chat_response)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn chat_create<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
server_id: String,
|
||||
message: String,
|
||||
message: Option<String>,
|
||||
attachments: Option<Vec<String>>,
|
||||
query_params: Option<HashMap<String, Value>>,
|
||||
client_id: String,
|
||||
) -> Result<(), String> {
|
||||
let body = if !message.is_empty() {
|
||||
let message = ChatRequestMessage {
|
||||
message: Some(message),
|
||||
println!("chat_create message: {:?}", message);
|
||||
println!("chat_create attachments: {:?}", attachments);
|
||||
|
||||
let message_empty = message.as_ref().map_or(true, |m| m.is_empty());
|
||||
let attachments_empty = attachments.as_ref().map_or(true, |a| a.is_empty());
|
||||
|
||||
if message_empty && attachments_empty {
|
||||
return Err("Message and attachments are empty".to_string());
|
||||
}
|
||||
|
||||
let body = {
|
||||
let request_message: ChatRequestMessage = ChatRequestMessage {
|
||||
message,
|
||||
attachments,
|
||||
};
|
||||
|
||||
println!("chat_create body: {:?}", request_message);
|
||||
|
||||
Some(
|
||||
serde_json::to_string(&message)
|
||||
serde_json::to_string(&request_message)
|
||||
.map_err(|e| format!("Failed to serialize message: {}", e))?
|
||||
.into(),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let response = HttpClient::advanced_post(
|
||||
@@ -213,8 +178,6 @@ pub async fn chat_create<R: Runtime>(
|
||||
if let Err(err) = app_handle.emit(&client_id, line) {
|
||||
log::error!("Emit failed: {:?}", err);
|
||||
|
||||
print!("Error sending message: {:?}", err);
|
||||
|
||||
let _ = app_handle.emit("chat-create-error", format!("Emit failed: {:?}", err));
|
||||
}
|
||||
}
|
||||
@@ -223,56 +186,38 @@ pub async fn chat_create<R: Runtime>(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn send_message<R: Runtime>(
|
||||
_app_handle: AppHandle<R>,
|
||||
server_id: String,
|
||||
websocket_id: String,
|
||||
session_id: String,
|
||||
message: String,
|
||||
query_params: Option<HashMap<String, Value>>, //search,deep_thinking
|
||||
) -> Result<String, String> {
|
||||
let path = format!("/chat/{}/_send", session_id);
|
||||
let msg = ChatRequestMessage {
|
||||
message: Some(message),
|
||||
};
|
||||
|
||||
let mut headers = HashMap::new();
|
||||
headers.insert("WEBSOCKET-SESSION-ID".to_string(), websocket_id.into());
|
||||
|
||||
let body = reqwest::Body::from(serde_json::to_string(&msg).unwrap());
|
||||
let response = HttpClient::advanced_post(
|
||||
&server_id,
|
||||
path.as_str(),
|
||||
Some(headers),
|
||||
convert_query_params_to_strings(query_params),
|
||||
Some(body),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Error cancel session: {}", e))?;
|
||||
|
||||
common::http::get_response_body_text(response).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn chat_chat<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
pub async fn chat_chat(
|
||||
app_handle: AppHandle,
|
||||
server_id: String,
|
||||
session_id: String,
|
||||
message: String,
|
||||
message: Option<String>,
|
||||
attachments: Option<Vec<String>>,
|
||||
query_params: Option<HashMap<String, Value>>, //search,deep_thinking
|
||||
client_id: String,
|
||||
) -> Result<(), String> {
|
||||
let body = if !message.is_empty() {
|
||||
let message = ChatRequestMessage {
|
||||
message: Some(message),
|
||||
println!("chat_chat message: {:?}", message);
|
||||
println!("chat_chat attachments: {:?}", attachments);
|
||||
|
||||
let message_empty = message.as_ref().map_or(true, |m| m.is_empty());
|
||||
let attachments_empty = attachments.as_ref().map_or(true, |a| a.is_empty());
|
||||
|
||||
if message_empty && attachments_empty {
|
||||
return Err("Message and attachments are empty".to_string());
|
||||
}
|
||||
|
||||
let body = {
|
||||
let request_message = ChatRequestMessage {
|
||||
message,
|
||||
attachments,
|
||||
};
|
||||
|
||||
println!("chat_chat body: {:?}", request_message);
|
||||
|
||||
Some(
|
||||
serde_json::to_string(&message)
|
||||
serde_json::to_string(&request_message)
|
||||
.map_err(|e| format!("Failed to serialize message: {}", e))?
|
||||
.into(),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let path = format!("/chat/{}/_chat", session_id);
|
||||
@@ -314,6 +259,9 @@ pub async fn chat_chat<R: Runtime>(
|
||||
|
||||
if let Err(err) = app_handle.emit(&client_id, line) {
|
||||
log::error!("Emit failed: {:?}", err);
|
||||
|
||||
print!("Error sending message: {:?}", err);
|
||||
|
||||
let _ = app_handle.emit("chat-create-error", format!("Emit failed: {:?}", err));
|
||||
}
|
||||
}
|
||||
@@ -365,8 +313,8 @@ pub async fn update_session_chat(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn assistant_search<R: Runtime>(
|
||||
_app_handle: AppHandle<R>,
|
||||
pub async fn assistant_search(
|
||||
_app_handle: AppHandle,
|
||||
server_id: String,
|
||||
query_params: Option<Vec<String>>,
|
||||
) -> Result<Value, String> {
|
||||
@@ -381,8 +329,8 @@ pub async fn assistant_search<R: Runtime>(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn assistant_get<R: Runtime>(
|
||||
_app_handle: AppHandle<R>,
|
||||
pub async fn assistant_get(
|
||||
_app_handle: AppHandle,
|
||||
server_id: String,
|
||||
assistant_id: String,
|
||||
) -> Result<Value, String> {
|
||||
@@ -405,8 +353,8 @@ pub async fn assistant_get<R: Runtime>(
|
||||
///
|
||||
/// Returns as soon as the assistant is found on any Coco server.
|
||||
#[tauri::command]
|
||||
pub async fn assistant_get_multi<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
pub async fn assistant_get_multi(
|
||||
app_handle: AppHandle,
|
||||
assistant_id: String,
|
||||
) -> Result<Value, String> {
|
||||
let search_sources = app_handle.state::<SearchSourceRegistry>();
|
||||
@@ -499,8 +447,8 @@ pub fn remove_icon_fields(json: &str) -> String {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn ask_ai<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
pub async fn ask_ai(
|
||||
app_handle: AppHandle,
|
||||
message: String,
|
||||
server_id: String,
|
||||
assistant_id: String,
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
use std::{fs::create_dir, io::Read};
|
||||
|
||||
use tauri::{Manager, Runtime};
|
||||
use tauri::{AppHandle, Manager};
|
||||
use tauri_plugin_autostart::ManagerExt;
|
||||
|
||||
/// If the state reported from the OS and the state stored by us differ, our state is
|
||||
/// prioritized and seen as the correct one. Update the OS state to make them consistent.
|
||||
pub fn ensure_autostart_state_consistent(app: &mut tauri::App) -> Result<(), String> {
|
||||
let autostart_manager = app.autolaunch();
|
||||
pub fn ensure_autostart_state_consistent(tauri_app_handle: &AppHandle) -> Result<(), String> {
|
||||
let autostart_manager = tauri_app_handle.autolaunch();
|
||||
|
||||
let os_state = autostart_manager.is_enabled().map_err(|e| e.to_string())?;
|
||||
let coco_stored_state = current_autostart(app.app_handle()).map_err(|e| e.to_string())?;
|
||||
let coco_stored_state = current_autostart(tauri_app_handle).map_err(|e| e.to_string())?;
|
||||
|
||||
if os_state != coco_stored_state {
|
||||
log::warn!(
|
||||
@@ -42,7 +42,7 @@ pub fn ensure_autostart_state_consistent(app: &mut tauri::App) -> Result<(), Str
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn current_autostart<R: Runtime>(app: &tauri::AppHandle<R>) -> Result<bool, String> {
|
||||
fn current_autostart(app: &tauri::AppHandle) -> Result<bool, String> {
|
||||
use std::fs::File;
|
||||
|
||||
let path = app.path().app_config_dir().unwrap();
|
||||
@@ -65,10 +65,7 @@ fn current_autostart<R: Runtime>(app: &tauri::AppHandle<R>) -> Result<bool, Stri
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn change_autostart<R: Runtime>(
|
||||
app: tauri::AppHandle<R>,
|
||||
open: bool,
|
||||
) -> Result<(), String> {
|
||||
pub async fn change_autostart(app: tauri::AppHandle, open: bool) -> Result<(), String> {
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
|
||||
|
||||
@@ -3,7 +3,10 @@ use serde_json::Value;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ChatRequestMessage {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub message: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub attachments: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
#[cfg(target_os = "macos")]
|
||||
use crate::extension::built_in::window_management::actions::Action;
|
||||
use crate::extension::{ExtensionPermission, ExtensionSettings};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use tauri::AppHandle;
|
||||
use tauri::Runtime;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RichLabel {
|
||||
@@ -31,17 +33,63 @@ pub struct EditorInfo {
|
||||
pub timestamp: Option<String>,
|
||||
}
|
||||
|
||||
/// Defines the action that would be performed when a document gets opened.
|
||||
/// Defines the action that would be performed when a [document](Document) gets opened.
|
||||
///
|
||||
/// "Document" is a uniform type that the backend uses to send the search results
|
||||
/// back to the frontend. Since Coco can search many sources, "Document" can
|
||||
/// represent different things, application, web page, local file, extensions, and
|
||||
/// so on. Each has its own specific open action.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub(crate) enum OnOpened {
|
||||
/// Launch the application
|
||||
Application { app_path: String },
|
||||
/// Open the URL.
|
||||
Document { url: String },
|
||||
/// Perform this WM action.
|
||||
#[cfg(target_os = "macos")]
|
||||
WindowManagementAction { action: Action },
|
||||
/// The document is an extension.
|
||||
Extension(ExtensionOnOpened),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub(crate) struct ExtensionOnOpened {
|
||||
/// Different types of extensions have different open behaviors.
|
||||
pub(crate) ty: ExtensionOnOpenedType,
|
||||
/// Extensions settings. Some could affect open action.
|
||||
///
|
||||
/// Optional because not all extensions have their settings.
|
||||
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)]
|
||||
pub(crate) enum ExtensionOnOpenedType {
|
||||
/// Spawn a child process to run the `CommandAction`.
|
||||
Command {
|
||||
action: crate::extension::CommandAction,
|
||||
},
|
||||
/// Open the `link`.
|
||||
//
|
||||
// NOTE that this variant has the same definition as `struct Quicklink`, but we
|
||||
// cannot use it directly, its `link` field should be deserialized/serialized
|
||||
// from/to a string, but we need a JSON object here.
|
||||
//
|
||||
// See also the comments in `struct Quicklink`.
|
||||
Quicklink {
|
||||
link: crate::extension::QuicklinkLink,
|
||||
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 {
|
||||
@@ -49,62 +97,158 @@ impl OnOpened {
|
||||
match self {
|
||||
Self::Application { app_path } => app_path.clone(),
|
||||
Self::Document { url } => url.clone(),
|
||||
Self::Command { action } => {
|
||||
const WHITESPACE: &str = " ";
|
||||
let mut ret = action.exec.clone();
|
||||
ret.push_str(WHITESPACE);
|
||||
if let Some(ref args) = action.args {
|
||||
ret.push_str(args.join(WHITESPACE).as_str());
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
Self::WindowManagementAction { action: _ } => {
|
||||
// We don't have URL for this
|
||||
String::from("N/A")
|
||||
}
|
||||
Self::Extension(ext_on_opened) => {
|
||||
match &ext_on_opened.ty {
|
||||
ExtensionOnOpenedType::Command { action } => {
|
||||
const WHITESPACE: &str = " ";
|
||||
let mut ret = action.exec.clone();
|
||||
ret.push_str(WHITESPACE);
|
||||
if let Some(ref args) = action.args {
|
||||
ret.push_str(args.join(WHITESPACE).as_str());
|
||||
}
|
||||
|
||||
ret
|
||||
ret
|
||||
}
|
||||
// Currently, our URL is static and does not support dynamic parameters.
|
||||
// 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".
|
||||
ExtensionOnOpenedType::Quicklink { .. } => String::from("N/A"),
|
||||
ExtensionOnOpenedType::View { page: _ } => {
|
||||
// We currently don't have URL for this kind of extension.
|
||||
String::from("N/A")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn open<R: Runtime>(
|
||||
tauri_app_handle: AppHandle<R>,
|
||||
pub(crate) async fn open(
|
||||
tauri_app_handle: AppHandle,
|
||||
on_opened: OnOpened,
|
||||
extra_args: Option<HashMap<String, String>>,
|
||||
) -> Result<(), String> {
|
||||
log::debug!("open({})", on_opened.url());
|
||||
|
||||
use crate::util::open as homemade_tauri_shell_open;
|
||||
use std::process::Command;
|
||||
|
||||
match on_opened {
|
||||
OnOpened::Application { app_path } => {
|
||||
log::debug!("open application [{}]", app_path);
|
||||
|
||||
homemade_tauri_shell_open(tauri_app_handle.clone(), app_path).await?
|
||||
}
|
||||
OnOpened::Document { url } => {
|
||||
log::debug!("open document [{}]", url);
|
||||
|
||||
homemade_tauri_shell_open(tauri_app_handle.clone(), url).await?
|
||||
}
|
||||
OnOpened::Command { action } => {
|
||||
let mut cmd = Command::new(action.exec);
|
||||
if let Some(args) = action.args {
|
||||
cmd.args(args);
|
||||
}
|
||||
let output = cmd.output().map_err(|e| e.to_string())?;
|
||||
// Sometimes, we wanna see the result in logs even though it doesn't fail.
|
||||
log::debug!(
|
||||
"executing open(Command) result, exit code: [{}], stdout: [{}], stderr: [{}]",
|
||||
output.status,
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
if !output.status.success() {
|
||||
log::warn!(
|
||||
"executing open(Command) failed, exit code: [{}], stdout: [{}], stderr: [{}]",
|
||||
output.status,
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
#[cfg(target_os = "macos")]
|
||||
OnOpened::WindowManagementAction { action } => {
|
||||
log::debug!("perform Window Management action [{:?}]", action);
|
||||
|
||||
return Err(format!(
|
||||
"Command failed, stderr [{}]",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
));
|
||||
crate::extension::built_in::window_management::perform_action_on_main_thread(
|
||||
&tauri_app_handle,
|
||||
action,
|
||||
)?;
|
||||
}
|
||||
OnOpened::Extension(ext_on_opened) => {
|
||||
// Apply the settings that would affect open behavior
|
||||
if let Some(settings) = ext_on_opened.settings {
|
||||
if let Some(should_hide) = settings.hide_before_open {
|
||||
if should_hide {
|
||||
crate::hide_coco(tauri_app_handle.clone()).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
let permission = ext_on_opened.permission;
|
||||
|
||||
match ext_on_opened.ty {
|
||||
ExtensionOnOpenedType::Command { action } => {
|
||||
log::debug!("open (execute) command [{:?}]", action);
|
||||
|
||||
let mut cmd = Command::new(action.exec);
|
||||
if let Some(args) = action.args {
|
||||
cmd.args(args);
|
||||
}
|
||||
let output = cmd.output().map_err(|e| e.to_string())?;
|
||||
// Sometimes, we wanna see the result in logs even though it doesn't fail.
|
||||
log::debug!(
|
||||
"executing open(Command) result, exit code: [{}], stdout: [{}], stderr: [{}]",
|
||||
output.status,
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
if !output.status.success() {
|
||||
log::warn!(
|
||||
"executing open(Command) failed, exit code: [{}], stdout: [{}], stderr: [{}]",
|
||||
output.status,
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
|
||||
return Err(format!(
|
||||
"Command failed, stderr [{}]",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
));
|
||||
}
|
||||
}
|
||||
ExtensionOnOpenedType::Quicklink {
|
||||
link,
|
||||
open_with: opt_open_with,
|
||||
} => {
|
||||
let url = link.concatenate_url(&extra_args);
|
||||
|
||||
log::debug!("open quicklink [{}] with [{:?}]", url, opt_open_with);
|
||||
|
||||
cfg_if::cfg_if! {
|
||||
// The `open_with` functionality is only supported on macOS, provided
|
||||
// by the `open -a` command.
|
||||
if #[cfg(target_os = "macos")] {
|
||||
let mut cmd = Command::new("open");
|
||||
if let Some(ref open_with) = opt_open_with {
|
||||
cmd.arg("-a");
|
||||
cmd.arg(open_with.as_str());
|
||||
}
|
||||
cmd.arg(&url);
|
||||
|
||||
let output = cmd.output().map_err(|e| format!("failed to spawn [open] due to error [{}]", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(format!(
|
||||
"failed to open with app {:?}: {}",
|
||||
opt_open_with,
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
));
|
||||
}
|
||||
} else {
|
||||
homemade_tauri_shell_open(tauri_app_handle.clone(), url).await?
|
||||
}
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -27,8 +27,8 @@ use pizza_engine::{Engine, EngineBuilder, doc};
|
||||
use serde_json::Value as Json;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use tauri::{AppHandle, Manager, Runtime, async_runtime};
|
||||
use tauri_plugin_fs_pro::{IconOptions, icon, metadata, name};
|
||||
use tauri::{AppHandle, Manager, async_runtime};
|
||||
use tauri_plugin_fs_pro::{IconOptions, icon, metadata};
|
||||
use tauri_plugin_global_shortcut::GlobalShortcutExt;
|
||||
use tauri_plugin_global_shortcut::Shortcut;
|
||||
use tauri_plugin_global_shortcut::ShortcutEvent;
|
||||
@@ -36,7 +36,13 @@ use tauri_plugin_global_shortcut::ShortcutState;
|
||||
use tauri_plugin_store::StoreExt;
|
||||
use tokio::sync::oneshot::Sender as OneshotSender;
|
||||
|
||||
// Deprecated. We no longer index this field, but to be backward-compatible, we
|
||||
// have to keep it.
|
||||
const FIELD_APP_NAME: &str = "app_name";
|
||||
|
||||
const FIELD_APP_NAME_IN_SYSTEM_LANG: &str = "app_name_in_system_lang";
|
||||
const FIELD_APP_NAME_ZH: &str = "app_name_zh";
|
||||
const FIELD_APP_NAME_EN: &str = "app_name_en";
|
||||
const FIELD_ICON_PATH: &str = "icon_path";
|
||||
const FIELD_APP_ALIAS: &str = "app_alias";
|
||||
const APPLICATION_SEARCH_SOURCE_ID: &str = "application";
|
||||
@@ -58,37 +64,18 @@ const INDEX_DIR: &str = "local_application_index";
|
||||
pub(crate) const QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME: &str = "Applications";
|
||||
|
||||
pub fn get_default_search_paths() -> Vec<String> {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let home_dir =
|
||||
PathBuf::from(std::env::var_os("HOME").expect("environment variable $HOME not found"));
|
||||
return vec![
|
||||
"/Applications".into(),
|
||||
"/System/Applications".into(),
|
||||
"/System/Library/CoreServices".into(),
|
||||
home_dir
|
||||
.join("Applications")
|
||||
.into_os_string()
|
||||
.into_string()
|
||||
.expect("this path should be UTF-8 encoded"),
|
||||
];
|
||||
let paths = applications::get_default_search_paths();
|
||||
let mut ret = Vec::with_capacity(paths.len());
|
||||
for search_path in paths {
|
||||
let path_string = search_path
|
||||
.into_os_string()
|
||||
.into_string()
|
||||
.expect("path should be UTF-8 encoded");
|
||||
|
||||
ret.push(path_string);
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
let paths = applications::get_default_search_paths();
|
||||
let mut ret = Vec::with_capacity(paths.len());
|
||||
for search_path in paths {
|
||||
let path_string = search_path
|
||||
.into_os_string()
|
||||
.into_string()
|
||||
.expect("path should be UTF-8 encoded");
|
||||
|
||||
ret.push(path_string);
|
||||
}
|
||||
|
||||
ret
|
||||
}
|
||||
ret
|
||||
}
|
||||
|
||||
/// Helper function to return `app`'s path.
|
||||
@@ -115,26 +102,63 @@ fn get_app_path(app: &App) -> String {
|
||||
.expect("should be UTF-8 encoded")
|
||||
}
|
||||
|
||||
/// Helper function to return `app`'s path.
|
||||
///
|
||||
/// * macOS: extract `app_path`'s file name and remove the file extension
|
||||
/// * Windows/Linux: return the name specified in `.desktop` file
|
||||
async fn get_app_name(app: &App) -> String {
|
||||
if cfg!(any(target_os = "linux", target_os = "windows")) {
|
||||
app.name.clone()
|
||||
/// Helper function to return `app`'s Chinese name.
|
||||
async fn get_app_name_zh(app: &App) -> String {
|
||||
// zh_CN or zh-CN
|
||||
if let Some(name) = app.localized_app_names.get("zh_CN") {
|
||||
return name.clone();
|
||||
}
|
||||
if let Some(name) = app.localized_app_names.get("zh-CN") {
|
||||
return name.clone();
|
||||
}
|
||||
|
||||
// zh_Hans or zh-Hans
|
||||
if let Some(name) = app.localized_app_names.get("zh_Hans") {
|
||||
return name.clone();
|
||||
}
|
||||
if let Some(name) = app.localized_app_names.get("zh-Hans") {
|
||||
return name.clone();
|
||||
}
|
||||
|
||||
// Fall back to base name
|
||||
app.name.clone()
|
||||
}
|
||||
|
||||
/// Helper function to return `app`'s English name.
|
||||
async fn get_app_name_en(app: &App) -> String {
|
||||
// en_US or en-US
|
||||
if let Some(name) = app.localized_app_names.get("en_US") {
|
||||
return name.clone();
|
||||
}
|
||||
if let Some(name) = app.localized_app_names.get("en-US") {
|
||||
return name.clone();
|
||||
}
|
||||
|
||||
// English (General)
|
||||
if let Some(name) = app.localized_app_names.get("en") {
|
||||
return name.clone();
|
||||
}
|
||||
|
||||
// Fall back to base name
|
||||
app.name.clone()
|
||||
}
|
||||
|
||||
/// Helper function to return `app`'s name in system language.
|
||||
async fn get_app_name_in_system_lang(app: &App) -> String {
|
||||
let system_lang = crate::util::system_lang::get_system_lang();
|
||||
|
||||
if let Some(name) = app.localized_app_names.get(&system_lang) {
|
||||
name.clone()
|
||||
} else {
|
||||
let app_path = get_app_path(app);
|
||||
name(app_path.into()).await
|
||||
// Fall back to base name
|
||||
app.name.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper function to return an absolute path to `app`'s icon.
|
||||
///
|
||||
/// On macOS/Windows, we cache icons in our data directory using the `icon()` function.
|
||||
async fn get_app_icon_path<R: Runtime>(
|
||||
tauri_app_handle: &AppHandle<R>,
|
||||
app: &App,
|
||||
) -> Result<String, String> {
|
||||
async fn get_app_icon_path(tauri_app_handle: &AppHandle, app: &App) -> Result<String, String> {
|
||||
let res_path = if cfg!(target_os = "linux") {
|
||||
let icon_path = app
|
||||
.icon_path
|
||||
@@ -213,8 +237,8 @@ impl SearchSourceState for ApplicationSearchSourceState {
|
||||
}
|
||||
|
||||
/// Index applications if they have not been indexed (by checking if `app_index_dir` exists).
|
||||
async fn index_applications_if_not_indexed<R: Runtime>(
|
||||
tauri_app_handle: &AppHandle<R>,
|
||||
async fn index_applications_if_not_indexed(
|
||||
tauri_app_handle: &AppHandle,
|
||||
app_index_dir: &Path,
|
||||
) -> anyhow::Result<ApplicationSearchSourceState> {
|
||||
let index_exists = app_index_dir.exists();
|
||||
@@ -224,9 +248,17 @@ async fn index_applications_if_not_indexed<R: Runtime>(
|
||||
pizza_engine_builder.set_data_store(disk_store);
|
||||
|
||||
let mut schema = Schema::new();
|
||||
let field_app_name = Property::builder(FieldType::Text).build();
|
||||
let field_app_name_zh = Property::builder(FieldType::Text).build();
|
||||
schema
|
||||
.add_property(FIELD_APP_NAME, field_app_name)
|
||||
.add_property(FIELD_APP_NAME_ZH, field_app_name_zh)
|
||||
.expect("no collision could happen");
|
||||
let field_app_name_en = Property::builder(FieldType::Text).build();
|
||||
schema
|
||||
.add_property(FIELD_APP_NAME_EN, field_app_name_en)
|
||||
.expect("no collision could happen");
|
||||
let field_app_name_in_system_lang = Property::builder(FieldType::Text).build();
|
||||
schema
|
||||
.add_property(FIELD_APP_NAME_IN_SYSTEM_LANG, field_app_name_in_system_lang)
|
||||
.expect("no collision could happen");
|
||||
let property_icon = Property::builder(FieldType::Text).index(false).build();
|
||||
schema
|
||||
@@ -271,21 +303,39 @@ async fn index_applications_if_not_indexed<R: Runtime>(
|
||||
|
||||
for app in apps.iter() {
|
||||
let app_path = get_app_path(app);
|
||||
let app_name = get_app_name(app).await;
|
||||
let app_name_zh = get_app_name_zh(app).await;
|
||||
let app_name_en = get_app_name_en(app).await;
|
||||
let app_name_in_system_lang = get_app_name_in_system_lang(app).await;
|
||||
let app_icon_path = get_app_icon_path(&tauri_app_handle, app)
|
||||
.await
|
||||
.map_err(|str| anyhow::anyhow!(str))?;
|
||||
let app_alias = get_app_alias(&tauri_app_handle, &app_path).unwrap_or(String::new());
|
||||
|
||||
if app_name.is_empty() || app_name.eq(&tauri_app_handle.package_info().name) {
|
||||
// Skip if all names are empty
|
||||
if app_name_zh.is_empty()
|
||||
&& app_name_en.is_empty()
|
||||
&& app_name_in_system_lang.is_empty()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if this is Coco itself
|
||||
//
|
||||
// Coco does not have localized app names, so app_name_en and app_name_zh
|
||||
// should both have value "Coco-AI", so either should work.
|
||||
if app_name_en == tauri_app_handle.package_info().name {
|
||||
continue;
|
||||
}
|
||||
|
||||
// You cannot write `app_name.clone()` within the `doc!()` macro, we should fix this.
|
||||
let app_name_clone = app_name.clone();
|
||||
let app_name_zh_clone = app_name_zh.clone();
|
||||
let app_name_en_clone = app_name_en.clone();
|
||||
let app_name_in_system_lang = app_name_in_system_lang.clone();
|
||||
let app_path_clone = app_path.clone();
|
||||
let document = doc!( app_path_clone, {
|
||||
FIELD_APP_NAME => app_name_clone,
|
||||
FIELD_APP_NAME_ZH => app_name_zh_clone,
|
||||
FIELD_APP_NAME_EN => app_name_en_clone,
|
||||
FIELD_APP_NAME_IN_SYSTEM_LANG => app_name_in_system_lang,
|
||||
FIELD_ICON_PATH => app_icon_path,
|
||||
FIELD_APP_ALIAS => app_alias,
|
||||
}
|
||||
@@ -294,8 +344,8 @@ async fn index_applications_if_not_indexed<R: Runtime>(
|
||||
// We don't error out because one failure won't break the whole thing
|
||||
if let Err(e) = writer.create_document(document).await {
|
||||
warn!(
|
||||
"failed to index application [app name: '{}', app path: '{}'] due to error [{}]",
|
||||
app_name, app_path, e
|
||||
"failed to index application [app name zh: '{}', app name en: '{}', app path: '{}'] due to error [{}]",
|
||||
app_name_zh, app_name_en, app_path, e
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -315,13 +365,13 @@ async fn index_applications_if_not_indexed<R: Runtime>(
|
||||
}
|
||||
|
||||
/// Upon application start, index all the applications found in the `get_default_search_paths()`.
|
||||
struct IndexAllApplicationsTask<R: Runtime> {
|
||||
tauri_app_handle: AppHandle<R>,
|
||||
struct IndexAllApplicationsTask {
|
||||
tauri_app_handle: AppHandle,
|
||||
callback: Option<tokio::sync::oneshot::Sender<Result<(), String>>>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait(?Send)]
|
||||
impl<R: Runtime> Task for IndexAllApplicationsTask<R> {
|
||||
impl Task for IndexAllApplicationsTask {
|
||||
fn search_source_id(&self) -> &'static str {
|
||||
APPLICATION_SEARCH_SOURCE_ID
|
||||
}
|
||||
@@ -343,13 +393,13 @@ impl<R: Runtime> Task for IndexAllApplicationsTask<R> {
|
||||
}
|
||||
}
|
||||
|
||||
struct ReindexAllApplicationsTask<R: Runtime> {
|
||||
tauri_app_handle: AppHandle<R>,
|
||||
struct ReindexAllApplicationsTask {
|
||||
tauri_app_handle: AppHandle,
|
||||
callback: Option<tokio::sync::oneshot::Sender<Result<(), String>>>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait(?Send)]
|
||||
impl<R: Runtime> Task for ReindexAllApplicationsTask<R> {
|
||||
impl Task for ReindexAllApplicationsTask {
|
||||
fn search_source_id(&self) -> &'static str {
|
||||
APPLICATION_SEARCH_SOURCE_ID
|
||||
}
|
||||
@@ -377,14 +427,14 @@ impl<R: Runtime> Task for ReindexAllApplicationsTask<R> {
|
||||
}
|
||||
}
|
||||
|
||||
struct SearchApplicationsTask<R: Runtime> {
|
||||
tauri_app_handle: AppHandle<R>,
|
||||
struct SearchApplicationsTask {
|
||||
tauri_app_handle: AppHandle,
|
||||
query_string: String,
|
||||
callback: Option<OneshotSender<Result<SearchResult, PizzaEngineError>>>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait(?Send)]
|
||||
impl<R: Runtime> Task for SearchApplicationsTask<R> {
|
||||
impl Task for SearchApplicationsTask {
|
||||
fn search_source_id(&self) -> &'static str {
|
||||
APPLICATION_SEARCH_SOURCE_ID
|
||||
}
|
||||
@@ -424,9 +474,19 @@ impl<R: Runtime> Task for SearchApplicationsTask<R> {
|
||||
//
|
||||
// It will be passed to Pizza like "Google\nChrome". Using Display impl would result
|
||||
// in an invalid query DSL and serde will complain.
|
||||
//
|
||||
// In order to be backward compatible, we still do match and prefix queries to the
|
||||
// app_name field.
|
||||
let dsl = format!(
|
||||
"{{ \"query\": {{ \"bool\": {{ \"should\": [ {{ \"match\": {{ \"{FIELD_APP_NAME}\": {:?} }} }}, {{ \"prefix\": {{ \"{FIELD_APP_NAME}\": {:?} }} }} ] }} }} }}",
|
||||
self.query_string, self.query_string
|
||||
"{{ \"query\": {{ \"bool\": {{ \"should\": [ {{ \"match\": {{ \"{FIELD_APP_NAME_ZH}\": {:?} }} }}, {{ \"prefix\": {{ \"{FIELD_APP_NAME_ZH}\": {:?} }} }}, {{ \"match\": {{ \"{FIELD_APP_NAME_EN}\": {:?} }} }}, {{ \"prefix\": {{ \"{FIELD_APP_NAME_EN}\": {:?} }} }}, {{ \"match\": {{ \"{FIELD_APP_NAME_IN_SYSTEM_LANG}\": {:?} }} }}, {{ \"prefix\": {{ \"{FIELD_APP_NAME_IN_SYSTEM_LANG}\": {:?} }} }}, {{ \"match\": {{ \"{FIELD_APP_NAME}\": {:?} }} }}, {{ \"prefix\": {{ \"{FIELD_APP_NAME}\": {:?} }} }} ] }} }} }}",
|
||||
self.query_string,
|
||||
self.query_string,
|
||||
self.query_string,
|
||||
self.query_string,
|
||||
self.query_string,
|
||||
self.query_string,
|
||||
self.query_string,
|
||||
self.query_string
|
||||
);
|
||||
|
||||
let state = state
|
||||
@@ -514,9 +574,7 @@ impl Task for IndexNewApplicationsTask {
|
||||
pub struct ApplicationSearchSource;
|
||||
|
||||
impl ApplicationSearchSource {
|
||||
pub async fn prepare_index_and_store<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
) -> Result<(), String> {
|
||||
pub async fn prepare_index_and_store(app_handle: AppHandle) -> Result<(), String> {
|
||||
app_handle
|
||||
.store(TAURI_STORE_APP_HOTKEY)
|
||||
.map_err(|e| e.to_string())?;
|
||||
@@ -625,7 +683,7 @@ impl SearchSource for ApplicationSearchSource {
|
||||
|
||||
let total_hits = search_result.total_hits;
|
||||
let source = self.get_type();
|
||||
let hits = pizza_engine_hits_to_coco_hits(search_result.hits);
|
||||
let hits = pizza_engine_hits_to_coco_hits(search_result.hits).await;
|
||||
|
||||
Ok(QueryResponse {
|
||||
source,
|
||||
@@ -635,9 +693,11 @@ impl SearchSource for ApplicationSearchSource {
|
||||
}
|
||||
}
|
||||
|
||||
fn pizza_engine_hits_to_coco_hits(
|
||||
async fn pizza_engine_hits_to_coco_hits(
|
||||
pizza_engine_hits: Option<Vec<PizzaEngineDocument>>,
|
||||
) -> Vec<(Document, f64)> {
|
||||
use crate::util::app_lang::{Lang, get_app_lang};
|
||||
|
||||
let Some(engine_hits) = pizza_engine_hits else {
|
||||
return Vec::new();
|
||||
};
|
||||
@@ -646,10 +706,43 @@ fn pizza_engine_hits_to_coco_hits(
|
||||
for engine_hit in engine_hits {
|
||||
let score = engine_hit.score.unwrap_or(0.0) as f64;
|
||||
let mut document_fields = engine_hit.fields;
|
||||
let app_name = match document_fields.remove(FIELD_APP_NAME).unwrap() {
|
||||
FieldValue::Text(string) => string,
|
||||
_ => unreachable!("field name is of type Text"),
|
||||
|
||||
// Get both Chinese and English names
|
||||
let opt_app_name_zh = match document_fields.remove(FIELD_APP_NAME_ZH) {
|
||||
Some(FieldValue::Text(string)) => Some(string),
|
||||
_ => None,
|
||||
};
|
||||
let opt_app_name_en = match document_fields.remove(FIELD_APP_NAME_EN) {
|
||||
Some(FieldValue::Text(string)) => Some(string),
|
||||
_ => None,
|
||||
};
|
||||
let opt_app_name_deprecated = match document_fields.remove(FIELD_APP_NAME) {
|
||||
Some(FieldValue::Text(string)) => Some(string),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let app_name: String = {
|
||||
if let Some(legacy_app_name) = opt_app_name_deprecated {
|
||||
// Old version of index, which only contains the field app_name.
|
||||
legacy_app_name
|
||||
} else {
|
||||
// New version of index store the following 2 fields
|
||||
|
||||
let panic_msg = format!(
|
||||
"new version of index should contain field [{}] and [{}]",
|
||||
FIELD_APP_NAME_EN, FIELD_APP_NAME_ZH
|
||||
);
|
||||
let app_name_zh = opt_app_name_zh.expect(&panic_msg);
|
||||
let app_name_en = opt_app_name_en.expect(&panic_msg);
|
||||
|
||||
// Choose the appropriate name based on current language
|
||||
match get_app_lang().await {
|
||||
Lang::zh_CN => app_name_zh,
|
||||
Lang::en_US => app_name_en,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let app_path = engine_hit.key.expect("key should be set to app path");
|
||||
let app_icon_path = match document_fields.remove(FIELD_ICON_PATH).unwrap() {
|
||||
FieldValue::Text(string) => string,
|
||||
@@ -669,7 +762,7 @@ fn pizza_engine_hits_to_coco_hits(
|
||||
}),
|
||||
id: app_path.clone(),
|
||||
category: Some("Application".to_string()),
|
||||
title: Some(app_name.clone()),
|
||||
title: Some(app_name),
|
||||
icon: Some(app_icon_path),
|
||||
on_opened: Some(on_opened),
|
||||
url: Some(url),
|
||||
@@ -683,7 +776,7 @@ fn pizza_engine_hits_to_coco_hits(
|
||||
coco_hits
|
||||
}
|
||||
|
||||
pub fn set_app_alias<R: Runtime>(tauri_app_handle: &AppHandle<R>, app_path: &str, alias: &str) {
|
||||
pub fn set_app_alias(tauri_app_handle: &AppHandle, app_path: &str, alias: &str) {
|
||||
let store = tauri_app_handle
|
||||
.store(TAURI_STORE_APP_ALIAS)
|
||||
.unwrap_or_else(|_| panic!("store [{}] not found/loaded", TAURI_STORE_APP_ALIAS));
|
||||
@@ -696,7 +789,7 @@ pub fn set_app_alias<R: Runtime>(tauri_app_handle: &AppHandle<R>, app_path: &str
|
||||
// deleted while updating it.
|
||||
}
|
||||
|
||||
fn get_app_alias<R: Runtime>(tauri_app_handle: &AppHandle<R>, app_path: &str) -> Option<String> {
|
||||
fn get_app_alias(tauri_app_handle: &AppHandle, app_path: &str) -> Option<String> {
|
||||
let store = tauri_app_handle
|
||||
.store(TAURI_STORE_APP_ALIAS)
|
||||
.unwrap_or_else(|_| panic!("store [{}] not found/loaded", TAURI_STORE_APP_ALIAS));
|
||||
@@ -714,9 +807,9 @@ fn get_app_alias<R: Runtime>(tauri_app_handle: &AppHandle<R>, app_path: &str) ->
|
||||
/// The handler that will be invoked when an application hotkey is pressed.
|
||||
///
|
||||
/// The `app_path` argument is for logging-only.
|
||||
fn app_hotkey_handler<R: Runtime>(
|
||||
fn app_hotkey_handler(
|
||||
app_path: String,
|
||||
) -> impl Fn(&AppHandle<R>, &Shortcut, ShortcutEvent) + Send + Sync + 'static {
|
||||
) -> impl Fn(&AppHandle, &Shortcut, ShortcutEvent) + Send + Sync + 'static {
|
||||
move |tauri_app_handle, _hot_key, event| {
|
||||
if event.state() == ShortcutState::Pressed {
|
||||
let app_path_clone = app_path.clone();
|
||||
@@ -732,7 +825,7 @@ fn app_hotkey_handler<R: Runtime>(
|
||||
}
|
||||
|
||||
/// For all the applications, if it is enabled & has hotkey set, then set it up.
|
||||
pub(crate) fn set_apps_hotkey<R: Runtime>(tauri_app_handle: &AppHandle<R>) -> Result<(), String> {
|
||||
pub(crate) fn set_apps_hotkey(tauri_app_handle: &AppHandle) -> Result<(), String> {
|
||||
let app_hotkey_store = tauri_app_handle
|
||||
.store(TAURI_STORE_APP_HOTKEY)
|
||||
.unwrap_or_else(|_| panic!("store [{}] not found/loaded", TAURI_STORE_APP_HOTKEY));
|
||||
@@ -756,7 +849,7 @@ pub(crate) fn set_apps_hotkey<R: Runtime>(tauri_app_handle: &AppHandle<R>) -> Re
|
||||
}
|
||||
|
||||
/// For all the applications, if it is enabled & has hotkey set, then unset it.
|
||||
pub(crate) fn unset_apps_hotkey<R: Runtime>(tauri_app_handle: &AppHandle<R>) -> Result<(), String> {
|
||||
pub(crate) fn unset_apps_hotkey(tauri_app_handle: &AppHandle) -> Result<(), String> {
|
||||
let app_hotkey_store = tauri_app_handle
|
||||
.store(TAURI_STORE_APP_HOTKEY)
|
||||
.unwrap_or_else(|_| panic!("store [{}] not found/loaded", TAURI_STORE_APP_HOTKEY));
|
||||
@@ -783,8 +876,8 @@ pub(crate) fn unset_apps_hotkey<R: Runtime>(tauri_app_handle: &AppHandle<R>) ->
|
||||
}
|
||||
|
||||
/// Set the hotkey but won't persist this settings change.
|
||||
pub(crate) fn set_app_hotkey<R: Runtime>(
|
||||
tauri_app_handle: &AppHandle<R>,
|
||||
pub(crate) fn set_app_hotkey(
|
||||
tauri_app_handle: &AppHandle,
|
||||
app_path: &str,
|
||||
hotkey: &str,
|
||||
) -> Result<(), String> {
|
||||
@@ -794,8 +887,8 @@ pub(crate) fn set_app_hotkey<R: Runtime>(
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub fn register_app_hotkey<R: Runtime>(
|
||||
tauri_app_handle: &AppHandle<R>,
|
||||
pub fn register_app_hotkey(
|
||||
tauri_app_handle: &AppHandle,
|
||||
app_path: &str,
|
||||
hotkey: &str,
|
||||
) -> Result<(), String> {
|
||||
@@ -812,10 +905,7 @@ pub fn register_app_hotkey<R: Runtime>(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn unregister_app_hotkey<R: Runtime>(
|
||||
tauri_app_handle: &AppHandle<R>,
|
||||
app_path: &str,
|
||||
) -> Result<(), String> {
|
||||
pub fn unregister_app_hotkey(tauri_app_handle: &AppHandle, app_path: &str) -> Result<(), String> {
|
||||
let app_hotkey_store = tauri_app_handle
|
||||
.store(TAURI_STORE_APP_HOTKEY)
|
||||
.unwrap_or_else(|_| panic!("store [{}] not found/loaded", TAURI_STORE_APP_HOTKEY));
|
||||
@@ -855,7 +945,7 @@ pub fn unregister_app_hotkey<R: Runtime>(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_disabled_app_list<R: Runtime>(tauri_app_handle: &AppHandle<R>) -> Vec<String> {
|
||||
fn get_disabled_app_list(tauri_app_handle: &AppHandle) -> Vec<String> {
|
||||
let store = tauri_app_handle
|
||||
.store(TAURI_STORE_DISABLED_APP_LIST_AND_SEARCH_PATH)
|
||||
.unwrap_or_else(|_| {
|
||||
@@ -892,10 +982,7 @@ pub fn is_app_search_enabled(app_path: &str) -> bool {
|
||||
disabled_app_list.iter().all(|path| path != app_path)
|
||||
}
|
||||
|
||||
pub fn disable_app_search<R: Runtime>(
|
||||
tauri_app_handle: &AppHandle<R>,
|
||||
app_path: &str,
|
||||
) -> Result<(), String> {
|
||||
pub fn disable_app_search(tauri_app_handle: &AppHandle, app_path: &str) -> Result<(), String> {
|
||||
let store = tauri_app_handle
|
||||
.store(TAURI_STORE_DISABLED_APP_LIST_AND_SEARCH_PATH)
|
||||
.unwrap_or_else(|_| {
|
||||
@@ -939,10 +1026,7 @@ pub fn disable_app_search<R: Runtime>(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn enable_app_search<R: Runtime>(
|
||||
tauri_app_handle: &AppHandle<R>,
|
||||
app_path: &str,
|
||||
) -> Result<(), String> {
|
||||
pub fn enable_app_search(tauri_app_handle: &AppHandle, app_path: &str) -> Result<(), String> {
|
||||
let store = tauri_app_handle
|
||||
.store(TAURI_STORE_DISABLED_APP_LIST_AND_SEARCH_PATH)
|
||||
.unwrap_or_else(|_| {
|
||||
@@ -984,8 +1068,8 @@ pub fn enable_app_search<R: Runtime>(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn add_app_search_path<R: Runtime>(
|
||||
tauri_app_handle: AppHandle<R>,
|
||||
pub async fn add_app_search_path(
|
||||
tauri_app_handle: AppHandle,
|
||||
search_path: String,
|
||||
) -> Result<(), String> {
|
||||
let mut search_paths = get_app_search_path(tauri_app_handle.clone()).await;
|
||||
@@ -1010,8 +1094,8 @@ pub async fn add_app_search_path<R: Runtime>(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn remove_app_search_path<R: Runtime>(
|
||||
tauri_app_handle: AppHandle<R>,
|
||||
pub async fn remove_app_search_path(
|
||||
tauri_app_handle: AppHandle,
|
||||
search_path: String,
|
||||
) -> Result<(), String> {
|
||||
let mut search_paths = get_app_search_path(tauri_app_handle.clone()).await;
|
||||
@@ -1036,7 +1120,7 @@ pub async fn remove_app_search_path<R: Runtime>(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_app_search_path<R: Runtime>(tauri_app_handle: AppHandle<R>) -> Vec<String> {
|
||||
pub async fn get_app_search_path(tauri_app_handle: AppHandle) -> Vec<String> {
|
||||
let store = tauri_app_handle
|
||||
.store(TAURI_STORE_DISABLED_APP_LIST_AND_SEARCH_PATH)
|
||||
.unwrap_or_else(|_| {
|
||||
@@ -1065,18 +1149,25 @@ pub async fn get_app_search_path<R: Runtime>(tauri_app_handle: AppHandle<R>) ->
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_app_list<R: Runtime>(
|
||||
tauri_app_handle: AppHandle<R>,
|
||||
) -> Result<Vec<Extension>, String> {
|
||||
pub async fn get_app_list(tauri_app_handle: AppHandle) -> Result<Vec<Extension>, String> {
|
||||
use crate::util::app_lang::{Lang, get_app_lang};
|
||||
|
||||
let search_paths = get_app_search_path(tauri_app_handle.clone()).await;
|
||||
let apps = list_app_in(search_paths)?;
|
||||
|
||||
let mut app_entries = Vec::with_capacity(apps.len());
|
||||
let lang = get_app_lang().await;
|
||||
|
||||
for app in apps {
|
||||
let name = get_app_name(&app).await;
|
||||
let name = match lang {
|
||||
Lang::zh_CN => get_app_name_zh(&app).await,
|
||||
Lang::en_US => get_app_name_en(&app).await,
|
||||
};
|
||||
|
||||
// filter out Coco-AI
|
||||
//
|
||||
// Coco does not have localized app names, so regardless the chosen language, name
|
||||
// should have value "Coco-AI".
|
||||
if name.eq(&tauri_app_handle.package_info().name) {
|
||||
continue;
|
||||
}
|
||||
@@ -1144,11 +1235,14 @@ pub async fn get_app_list<R: Runtime>(
|
||||
quicklink: None,
|
||||
commands: None,
|
||||
scripts: None,
|
||||
views: None,
|
||||
quicklinks: None,
|
||||
alias: Some(alias),
|
||||
hotkey,
|
||||
enabled,
|
||||
settings: None,
|
||||
page: None,
|
||||
permission: None,
|
||||
screenshots: None,
|
||||
url: None,
|
||||
version: None,
|
||||
@@ -1202,9 +1296,7 @@ pub async fn get_app_metadata(app_name: String, app_path: String) -> Result<AppM
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn reindex_applications<R: Runtime>(
|
||||
tauri_app_handle: AppHandle<R>,
|
||||
) -> Result<(), String> {
|
||||
pub async fn reindex_applications(tauri_app_handle: AppHandle) -> Result<(), String> {
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
let reindex_applications_task = ReindexAllApplicationsTask {
|
||||
tauri_app_handle: tauri_app_handle.clone(),
|
||||
|
||||
@@ -5,16 +5,14 @@ use crate::common::search::{QueryResponse, QuerySource, SearchQuery};
|
||||
use crate::common::traits::SearchSource;
|
||||
use crate::extension::LOCAL_QUERY_SOURCE_TYPE;
|
||||
use async_trait::async_trait;
|
||||
use tauri::{AppHandle, Runtime};
|
||||
use tauri::AppHandle;
|
||||
|
||||
pub(crate) const QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME: &str = "Applications";
|
||||
|
||||
pub struct ApplicationSearchSource;
|
||||
|
||||
impl ApplicationSearchSource {
|
||||
pub async fn prepare_index_and_store<R: Runtime>(
|
||||
_app_handle: AppHandle<R>,
|
||||
) -> Result<(), String> {
|
||||
pub async fn prepare_index_and_store(_app_handle: AppHandle) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -45,37 +43,28 @@ impl SearchSource for ApplicationSearchSource {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_app_alias<R: Runtime>(_tauri_app_handle: &AppHandle<R>, _app_path: &str, _alias: &str) {
|
||||
pub fn set_app_alias(_tauri_app_handle: &AppHandle, _app_path: &str, _alias: &str) {
|
||||
unreachable!("app list should be empty, there is no way this can be invoked")
|
||||
}
|
||||
|
||||
pub fn register_app_hotkey<R: Runtime>(
|
||||
_tauri_app_handle: &AppHandle<R>,
|
||||
pub fn register_app_hotkey(
|
||||
_tauri_app_handle: &AppHandle,
|
||||
_app_path: &str,
|
||||
_hotkey: &str,
|
||||
) -> Result<(), String> {
|
||||
unreachable!("app list should be empty, there is no way this can be invoked")
|
||||
}
|
||||
|
||||
pub fn unregister_app_hotkey<R: Runtime>(
|
||||
_tauri_app_handle: &AppHandle<R>,
|
||||
_app_path: &str,
|
||||
) -> Result<(), String> {
|
||||
pub fn unregister_app_hotkey(_tauri_app_handle: &AppHandle, _app_path: &str) -> Result<(), String> {
|
||||
unreachable!("app list should be empty, there is no way this can be invoked")
|
||||
}
|
||||
|
||||
pub fn disable_app_search<R: Runtime>(
|
||||
_tauri_app_handle: &AppHandle<R>,
|
||||
_app_path: &str,
|
||||
) -> Result<(), String> {
|
||||
pub fn disable_app_search(_tauri_app_handle: &AppHandle, _app_path: &str) -> Result<(), String> {
|
||||
// no-op
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn enable_app_search<R: Runtime>(
|
||||
_tauri_app_handle: &AppHandle<R>,
|
||||
_app_path: &str,
|
||||
) -> Result<(), String> {
|
||||
pub fn enable_app_search(_tauri_app_handle: &AppHandle, _app_path: &str) -> Result<(), String> {
|
||||
// no-op
|
||||
Ok(())
|
||||
}
|
||||
@@ -85,8 +74,8 @@ pub fn is_app_search_enabled(_app_path: &str) -> bool {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn add_app_search_path<R: Runtime>(
|
||||
_tauri_app_handle: AppHandle<R>,
|
||||
pub async fn add_app_search_path(
|
||||
_tauri_app_handle: AppHandle,
|
||||
_search_path: String,
|
||||
) -> Result<(), String> {
|
||||
// no-op
|
||||
@@ -94,8 +83,8 @@ pub async fn add_app_search_path<R: Runtime>(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn remove_app_search_path<R: Runtime>(
|
||||
_tauri_app_handle: AppHandle<R>,
|
||||
pub async fn remove_app_search_path(
|
||||
_tauri_app_handle: AppHandle,
|
||||
_search_path: String,
|
||||
) -> Result<(), String> {
|
||||
// no-op
|
||||
@@ -103,43 +92,37 @@ pub async fn remove_app_search_path<R: Runtime>(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_app_search_path<R: Runtime>(_tauri_app_handle: AppHandle<R>) -> Vec<String> {
|
||||
pub async fn get_app_search_path(_tauri_app_handle: AppHandle) -> Vec<String> {
|
||||
// Return an empty list
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_app_list<R: Runtime>(
|
||||
_tauri_app_handle: AppHandle<R>,
|
||||
) -> Result<Vec<Extension>, String> {
|
||||
pub async fn get_app_list(_tauri_app_handle: AppHandle) -> Result<Vec<Extension>, String> {
|
||||
// Return an empty list
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_app_metadata<R: Runtime>(
|
||||
_tauri_app_handle: AppHandle<R>,
|
||||
pub async fn get_app_metadata(
|
||||
_tauri_app_handle: AppHandle,
|
||||
_app_path: String,
|
||||
) -> Result<AppMetadata, String> {
|
||||
unreachable!("app list should be empty, there is no way this can be invoked")
|
||||
}
|
||||
|
||||
pub(crate) fn set_apps_hotkey<R: Runtime>(_tauri_app_handle: &AppHandle<R>) -> Result<(), String> {
|
||||
pub(crate) fn set_apps_hotkey(_tauri_app_handle: &AppHandle) -> Result<(), String> {
|
||||
// no-op
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn unset_apps_hotkey<R: Runtime>(
|
||||
_tauri_app_handle: &AppHandle<R>,
|
||||
) -> Result<(), String> {
|
||||
pub(crate) fn unset_apps_hotkey(_tauri_app_handle: &AppHandle) -> Result<(), String> {
|
||||
// no-op
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn reindex_applications<R: Runtime>(
|
||||
_tauri_app_handle: AppHandle<R>,
|
||||
) -> Result<(), String> {
|
||||
pub async fn reindex_applications(_tauri_app_handle: AppHandle) -> Result<(), String> {
|
||||
// no-op
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
//! File Search configuration entries definition and getter/setter functions.
|
||||
|
||||
use crate::extension::built_in::file_search::implementation::apply_config;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use std::sync::LazyLock;
|
||||
use tauri::AppHandle;
|
||||
use tauri::Runtime;
|
||||
use tauri_plugin_store::StoreExt;
|
||||
|
||||
// Tauri store keys for file system configuration
|
||||
@@ -24,7 +24,7 @@ static HOME_DIR: LazyLock<String> = LazyLock::new(|| {
|
||||
.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 {
|
||||
Name,
|
||||
NameAndContents,
|
||||
@@ -54,7 +54,7 @@ impl Default for FileSearchConfig {
|
||||
}
|
||||
|
||||
impl FileSearchConfig {
|
||||
pub(crate) fn get<R: Runtime>(tauri_app_handle: &AppHandle<R>) -> Self {
|
||||
pub(crate) fn get(tauri_app_handle: &AppHandle) -> Self {
|
||||
let store = tauri_app_handle
|
||||
.store(TAURI_STORE_FILE_SYSTEM_CONFIG)
|
||||
.unwrap_or_else(|e| {
|
||||
@@ -185,28 +185,32 @@ impl FileSearchConfig {
|
||||
|
||||
// Tauri commands for managing file system configuration
|
||||
#[tauri::command]
|
||||
pub async fn get_file_system_config<R: Runtime>(
|
||||
tauri_app_handle: AppHandle<R>,
|
||||
) -> FileSearchConfig {
|
||||
pub async fn get_file_system_config(tauri_app_handle: AppHandle) -> FileSearchConfig {
|
||||
FileSearchConfig::get(&tauri_app_handle)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_file_system_config<R: Runtime>(
|
||||
tauri_app_handle: AppHandle<R>,
|
||||
pub async fn set_file_system_config(
|
||||
tauri_app_handle: AppHandle,
|
||||
config: FileSearchConfig,
|
||||
) -> Result<(), String> {
|
||||
let store = tauri_app_handle
|
||||
.store(TAURI_STORE_FILE_SYSTEM_CONFIG)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
store.set(TAURI_STORE_KEY_SEARCH_PATHS, config.search_paths);
|
||||
store.set(TAURI_STORE_KEY_EXCLUDE_PATHS, config.exclude_paths);
|
||||
store.set(TAURI_STORE_KEY_FILE_TYPES, config.file_types);
|
||||
store.set(TAURI_STORE_KEY_SEARCH_PATHS, config.search_paths.as_slice());
|
||||
store.set(
|
||||
TAURI_STORE_KEY_EXCLUDE_PATHS,
|
||||
config.exclude_paths.as_slice(),
|
||||
);
|
||||
store.set(TAURI_STORE_KEY_FILE_TYPES, config.file_types.as_slice());
|
||||
store.set(
|
||||
TAURI_STORE_KEY_SEARCH_BY,
|
||||
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(())
|
||||
}
|
||||
|
||||
@@ -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::config::FileSearchConfig;
|
||||
use super::super::config::SearchBy;
|
||||
use 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::get_file_icon;
|
||||
use crate::util::file::sync_get_file_icon;
|
||||
use futures::stream::Stream;
|
||||
use futures::stream::StreamExt;
|
||||
use std::os::fd::OwnedFd;
|
||||
@@ -24,7 +25,7 @@ pub(crate) async fn hits(
|
||||
size: usize,
|
||||
config: &FileSearchConfig,
|
||||
) -> 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)?;
|
||||
|
||||
// Convert results to documents
|
||||
@@ -32,7 +33,7 @@ pub(crate) async fn hits(
|
||||
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 = 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 r#where = file_path_of_type_path
|
||||
.parent()
|
||||
@@ -72,12 +73,6 @@ pub(crate) async fn hits(
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -88,13 +83,28 @@ fn build_mdfind_query(query_string: &str, config: &FileSearchConfig) -> Vec<Stri
|
||||
|
||||
match config.search_by {
|
||||
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 => {
|
||||
args.push(format!(
|
||||
"kMDItemFSName == '*{}*' || kMDItemTextContent == '{}'",
|
||||
query_string, query_string
|
||||
));
|
||||
// Do not specify any File System Metadata Attribute Keys to search
|
||||
// all of them, it is case-insensitive by default.
|
||||
//
|
||||
// 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:
|
||||
///
|
||||
/// * 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
|
||||
/// collect all the results to avoid zombie processes.
|
||||
/// * Child: The handle to the mdfind process. The child process will be killed
|
||||
/// when this handle gets dropped, we need to keep it alive until we exhaust
|
||||
/// all the query results.
|
||||
fn execute_mdfind_query(
|
||||
query_string: &str,
|
||||
from: usize,
|
||||
@@ -133,6 +144,7 @@ fn execute_mdfind_query(
|
||||
.args(&args[1..])
|
||||
.stdout(tx)
|
||||
.stderr(std::process::Stdio::null())
|
||||
.kill_on_drop(true)
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to spawn mdfind: {}", e))?;
|
||||
let config_clone = config.clone();
|
||||
@@ -140,7 +152,7 @@ fn execute_mdfind_query(
|
||||
.filter(move |res_path| {
|
||||
std::future::ready({
|
||||
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(_) => {
|
||||
// Don't filter out Err() values
|
||||
true
|
||||
@@ -154,33 +166,25 @@ fn execute_mdfind_query(
|
||||
Ok((iter, child))
|
||||
}
|
||||
|
||||
/// If `file_path` should be removed from the search results given the filter
|
||||
/// conditions specified in `config`.
|
||||
fn should_be_filtered_out(config: &FileSearchConfig, file_path: &str) -> bool {
|
||||
let is_excluded = config
|
||||
.exclude_paths
|
||||
.iter()
|
||||
.any(|exclude_path| file_path.starts_with(exclude_path));
|
||||
|
||||
if is_excluded {
|
||||
return true;
|
||||
}
|
||||
|
||||
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, then the search results
|
||||
// should have extensions.
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
!matches_file_type
|
||||
pub(crate) fn apply_config(_: &FileSearchConfig) -> Result<(), String> {
|
||||
// By default, macOS indexes all the files within a volume if indexing is
|
||||
// enabled. So, to ensure our search paths are indexed by Spotlight,
|
||||
// theoretically, we can do the following things:
|
||||
//
|
||||
// 1. Ensure indexing is enabled on the volumes where our search paths reside.
|
||||
// However, we cannot do this as doing so requires `sudo`.
|
||||
//
|
||||
// 2. Ensure the search paths are not excluded from indexing scope. Users can
|
||||
// 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
|
||||
// through System Settings.
|
||||
// 2. Renaming directory name, adding a `.noindex` file extension to it.
|
||||
// I don't want to use this trick, users won't feel comfortable and it
|
||||
// could break at any time.
|
||||
// 3. Creating a `.metadata_never_index` file within the directory (no longer works
|
||||
// since macOS Mojave)
|
||||
//
|
||||
// There is nothing we can do.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,10 +1,396 @@
|
||||
#[cfg(target_os = "macos")]
|
||||
mod macos;
|
||||
#[cfg(target_os = "windows")]
|
||||
mod windows;
|
||||
use cfg_if::cfg_if;
|
||||
|
||||
// `hits()` function is platform-specific, export the corresponding impl.
|
||||
#[cfg(target_os = "macos")]
|
||||
pub(crate) use macos::hits;
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) use windows::hits;
|
||||
// * hits: the implementation of search
|
||||
//
|
||||
// * apply_config: Routines that should be performed to keep "other things"
|
||||
// synchronous with the passed configuration.
|
||||
// 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
|
||||
//! is the starting point of this implementation.
|
||||
|
||||
mod crawl_scope_manager;
|
||||
|
||||
use super::super::EXTENSION_ID;
|
||||
use super::super::config::FileSearchConfig;
|
||||
use super::super::config::SearchBy;
|
||||
use crate::common::document::{DataSourceReference, Document};
|
||||
use crate::extension::LOCAL_QUERY_SOURCE_TYPE;
|
||||
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::{
|
||||
Win32::System::{
|
||||
Com::{CLSCTX_INPROC_SERVER, CoCreateInstance},
|
||||
@@ -420,7 +424,7 @@ pub(crate) async fn hits(
|
||||
// "file:C:/Users/desktop.ini" => "C:/Users/desktop.ini"
|
||||
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 r#where = file_path_of_type_path
|
||||
.parent()
|
||||
@@ -468,6 +472,85 @@ pub(crate) async fn 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
|
||||
// "SQL is invalid: "0x80041820""
|
||||
//
|
||||
@@ -19,7 +19,7 @@ pub(crate) const PLUGIN_JSON_FILE: &str = r#"
|
||||
{
|
||||
"id": "File Search",
|
||||
"name": "File Search",
|
||||
"platforms": ["macos", "windows"],
|
||||
"platforms": ["macos", "windows", "linux"],
|
||||
"description": "Search files on your system",
|
||||
"icon": "font_Filesearch",
|
||||
"type": "extension"
|
||||
|
||||
@@ -3,10 +3,11 @@
|
||||
pub mod ai_overview;
|
||||
pub mod application;
|
||||
pub mod calculator;
|
||||
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
||||
pub mod file_search;
|
||||
pub mod pizza_engine_runtime;
|
||||
pub mod quick_ai_access;
|
||||
#[cfg(target_os = "macos")]
|
||||
pub mod window_management;
|
||||
|
||||
use super::Extension;
|
||||
use crate::SearchSourceRegistry;
|
||||
@@ -15,12 +16,12 @@ use crate::extension::{
|
||||
ExtensionBundleIdBorrowed, PLUGIN_JSON_FILE_NAME, alter_extension_json_file,
|
||||
};
|
||||
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 tauri::{AppHandle, Manager, Runtime};
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
pub(crate) fn get_built_in_extension_directory<R: Runtime>(
|
||||
tauri_app_handle: &AppHandle<R>,
|
||||
) -> PathBuf {
|
||||
pub(crate) fn get_built_in_extension_directory(tauri_app_handle: &AppHandle) -> PathBuf {
|
||||
let mut resource_dir = tauri_app_handle.path().app_data_dir().expect(
|
||||
"User home directory not found, which should be impossible on desktop environments",
|
||||
);
|
||||
@@ -136,8 +137,8 @@ async fn load_built_in_extension(
|
||||
/// We only read alias/hotkey/enabled from the JSON file, we have ensured that if
|
||||
/// alias/hotkey is not supported, then it will be `None`. Besides that, no further
|
||||
/// validation is needed because nothing could go wrong.
|
||||
pub(crate) async fn list_built_in_extensions<R: Runtime>(
|
||||
tauri_app_handle: &AppHandle<R>,
|
||||
pub(crate) async fn list_built_in_extensions(
|
||||
tauri_app_handle: &AppHandle,
|
||||
) -> Result<Vec<Extension>, String> {
|
||||
let dir = get_built_in_extension_directory(tauri_app_handle);
|
||||
|
||||
@@ -175,24 +176,33 @@ pub(crate) async fn list_built_in_extensions<R: Runtime>(
|
||||
.await?,
|
||||
);
|
||||
|
||||
built_in_extensions.push(
|
||||
load_built_in_extension(
|
||||
&dir,
|
||||
file_search::EXTENSION_ID,
|
||||
file_search::PLUGIN_JSON_FILE,
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(any(target_os = "macos", target_os = "windows"))] {
|
||||
built_in_extensions.push(
|
||||
load_built_in_extension(
|
||||
&dir,
|
||||
file_search::EXTENSION_ID,
|
||||
file_search::PLUGIN_JSON_FILE,
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
}
|
||||
if #[cfg(target_os = "macos")] {
|
||||
built_in_extensions.push(
|
||||
load_built_in_extension(
|
||||
&dir,
|
||||
window_management::EXTENSION_ID,
|
||||
window_management::PLUGIN_JSON_FILE,
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(built_in_extensions)
|
||||
}
|
||||
|
||||
pub(super) async fn init_built_in_extension<R: Runtime>(
|
||||
tauri_app_handle: &AppHandle<R>,
|
||||
pub(super) async fn init_built_in_extension(
|
||||
tauri_app_handle: &AppHandle,
|
||||
extension: &Extension,
|
||||
search_source_registry: &SearchSourceRegistry,
|
||||
) -> Result<(), String> {
|
||||
@@ -214,16 +224,28 @@ pub(super) async fn init_built_in_extension<R: Runtime>(
|
||||
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! {
|
||||
if #[cfg(any(target_os = "macos", target_os = "windows"))] {
|
||||
if extension.id == file_search::EXTENSION_ID {
|
||||
let file_system_search = file_search::FileSearchExtensionSearchSource;
|
||||
search_source_registry
|
||||
.register_source(file_system_search)
|
||||
.await;
|
||||
log::debug!("built-in extension [{}] initialized", extension.id);
|
||||
}
|
||||
}
|
||||
if #[cfg(target_os = "macos")] {
|
||||
if extension.id == window_management::EXTENSION_ID {
|
||||
let file_system_search = window_management::search_source::WindowManagementSearchSource;
|
||||
search_source_registry
|
||||
.register_source(file_system_search)
|
||||
.await;
|
||||
|
||||
window_management::set_up_commands_hotkeys(tauri_app_handle, extension)?;
|
||||
log::debug!("built-in extension [{}] initialized", extension.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -233,8 +255,8 @@ pub(crate) fn is_extension_built_in(bundle_id: &ExtensionBundleIdBorrowed<'_>) -
|
||||
bundle_id.developer.is_none()
|
||||
}
|
||||
|
||||
pub(crate) async fn enable_built_in_extension<R: Runtime>(
|
||||
tauri_app_handle: &AppHandle<R>,
|
||||
pub(crate) async fn enable_built_in_extension(
|
||||
tauri_app_handle: &AppHandle,
|
||||
bundle_id: &ExtensionBundleIdBorrowed<'_>,
|
||||
) -> Result<(), String> {
|
||||
let search_source_registry_tauri_state = tauri_app_handle.state::<SearchSourceRegistry>();
|
||||
@@ -301,19 +323,51 @@ pub(crate) async fn enable_built_in_extension<R: Runtime>(
|
||||
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! {
|
||||
if #[cfg(any(target_os = "macos", target_os = "windows"))] {
|
||||
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,
|
||||
)?;
|
||||
return Ok(());
|
||||
if #[cfg(target_os = "macos")] {
|
||||
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);
|
||||
|
||||
let file_system_search = window_management::search_source::WindowManagementSearchSource;
|
||||
search_source_registry_tauri_state
|
||||
.register_source(file_system_search)
|
||||
.await;
|
||||
|
||||
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)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -321,8 +375,8 @@ pub(crate) async fn enable_built_in_extension<R: Runtime>(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn disable_built_in_extension<R: Runtime>(
|
||||
tauri_app_handle: &AppHandle<R>,
|
||||
pub(crate) async fn disable_built_in_extension(
|
||||
tauri_app_handle: &AppHandle,
|
||||
bundle_id: &ExtensionBundleIdBorrowed<'_>,
|
||||
) -> Result<(), String> {
|
||||
let search_source_registry_tauri_state = tauri_app_handle.state::<SearchSourceRegistry>();
|
||||
@@ -389,18 +443,44 @@ pub(crate) async fn disable_built_in_extension<R: Runtime>(
|
||||
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! {
|
||||
if #[cfg(any(target_os = "macos", target_os = "windows"))] {
|
||||
if bundle_id.extension_id == file_search::EXTENSION_ID {
|
||||
if #[cfg(target_os = "macos")] {
|
||||
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
|
||||
.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(());
|
||||
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_commands_hotkeys(tauri_app_handle, &extension)?;
|
||||
}
|
||||
|
||||
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)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -408,20 +488,40 @@ pub(crate) async fn disable_built_in_extension<R: Runtime>(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn set_built_in_extension_alias<R: Runtime>(
|
||||
tauri_app_handle: &AppHandle<R>,
|
||||
pub(crate) fn set_built_in_extension_alias(
|
||||
tauri_app_handle: &AppHandle,
|
||||
bundle_id: &ExtensionBundleIdBorrowed<'_>,
|
||||
alias: &str,
|
||||
) {
|
||||
) -> Result<(), String> {
|
||||
if bundle_id.extension_id == application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME {
|
||||
if let Some(app_path) = bundle_id.sub_extension_id {
|
||||
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<R: Runtime>(
|
||||
tauri_app_handle: &AppHandle<R>,
|
||||
pub(crate) fn register_built_in_extension_hotkey(
|
||||
tauri_app_handle: &AppHandle,
|
||||
bundle_id: &ExtensionBundleIdBorrowed<'_>,
|
||||
hotkey: &str,
|
||||
) -> Result<(), String> {
|
||||
@@ -430,11 +530,34 @@ pub(crate) fn register_built_in_extension_hotkey<R: Runtime>(
|
||||
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(())
|
||||
}
|
||||
|
||||
pub(crate) fn unregister_built_in_extension_hotkey<R: Runtime>(
|
||||
tauri_app_handle: &AppHandle<R>,
|
||||
pub(crate) fn unregister_built_in_extension_hotkey(
|
||||
tauri_app_handle: &AppHandle,
|
||||
bundle_id: &ExtensionBundleIdBorrowed<'_>,
|
||||
) -> Result<(), String> {
|
||||
if bundle_id.extension_id == application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME {
|
||||
@@ -442,6 +565,35 @@ pub(crate) fn unregister_built_in_extension_hotkey<R: Runtime>(
|
||||
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(())
|
||||
}
|
||||
|
||||
@@ -481,8 +633,10 @@ fn load_extension_from_json_file(
|
||||
Ok(extension)
|
||||
}
|
||||
|
||||
pub(crate) async fn is_built_in_extension_enabled<R: Runtime>(
|
||||
tauri_app_handle: &AppHandle<R>,
|
||||
#[allow(unused_macros)] // #[function_name::named] only used on macOS
|
||||
#[function_name::named]
|
||||
pub(crate) async fn is_built_in_extension_enabled(
|
||||
tauri_app_handle: &AppHandle,
|
||||
bundle_id: &ExtensionBundleIdBorrowed<'_>,
|
||||
) -> Result<bool, String> {
|
||||
let search_source_registry_tauri_state = tauri_app_handle.state::<SearchSourceRegistry>();
|
||||
@@ -526,9 +680,17 @@ pub(crate) async fn is_built_in_extension_enabled<R: Runtime>(
|
||||
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! {
|
||||
if #[cfg(any(target_os = "macos", target_os = "windows"))] {
|
||||
if bundle_id.extension_id == file_search::EXTENSION_ID
|
||||
if #[cfg(target_os = "macos")] {
|
||||
// Window Management
|
||||
if bundle_id.extension_id == window_management::EXTENSION_ID
|
||||
&& bundle_id.sub_extension_id.is_none()
|
||||
{
|
||||
return Ok(search_source_registry_tauri_state
|
||||
@@ -536,6 +698,25 @@ pub(crate) async fn is_built_in_extension_enabled<R: Runtime>(
|
||||
.await
|
||||
.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,
|
||||
})
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
789
src-tauri/src/extension/third_party/check.rs
vendored
Normal file
789
src-tauri/src/extension/third_party/check.rs
vendored
Normal file
@@ -0,0 +1,789 @@
|
||||
//! Coco has 4 sources of `plugin.json` to check and validate:
|
||||
//!
|
||||
//! 1. From coco-extensions repository
|
||||
//!
|
||||
//! Granted, Coco APP won't check these files directly, but the code here
|
||||
//! will run in that repository's CI to prevent errors in the first place.
|
||||
//!
|
||||
//! 2. From the "<data directory>/third_party_extensions" directory
|
||||
//! 3. Imported via "Import Local Extension"
|
||||
//! 4. Downloaded from the "store/extension/<extension ID>/_download" API
|
||||
//!
|
||||
//! This file contains the checks that are general enough to be applied to all
|
||||
//! these 4 sources
|
||||
|
||||
use crate::extension::Extension;
|
||||
use crate::extension::ExtensionType;
|
||||
use crate::util::platform::Platform;
|
||||
use std::collections::HashSet;
|
||||
|
||||
pub(crate) fn general_check(extension: &Extension) -> Result<(), String> {
|
||||
// Check main extension
|
||||
check_main_extension_only(extension)?;
|
||||
check_main_extension_or_sub_extension(extension, &format!("extension [{}]", extension.id))?;
|
||||
|
||||
// `None` if `extension` is compatible with all the platforms. Otherwise `Some(limited_platforms)`
|
||||
let limited_supported_platforms = match extension.platforms.as_ref() {
|
||||
Some(platforms) => {
|
||||
if platforms.len() == Platform::num_of_supported_platforms() {
|
||||
None
|
||||
} else {
|
||||
Some(platforms)
|
||||
}
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
|
||||
// Check sub extensions
|
||||
let commands = match extension.commands {
|
||||
Some(ref v) => v.as_slice(),
|
||||
None => &[],
|
||||
};
|
||||
let scripts = match extension.scripts {
|
||||
Some(ref v) => v.as_slice(),
|
||||
None => &[],
|
||||
};
|
||||
let quicklinks = match extension.quicklinks {
|
||||
Some(ref v) => v.as_slice(),
|
||||
None => &[],
|
||||
};
|
||||
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();
|
||||
|
||||
for sub_extension in sub_extensions.iter() {
|
||||
check_sub_extension_only(&extension.id, sub_extension, limited_supported_platforms)?;
|
||||
check_main_extension_or_sub_extension(
|
||||
extension,
|
||||
&format!("sub-extension [{}-{}]", extension.id, sub_extension.id),
|
||||
)?;
|
||||
|
||||
if !sub_extension_ids.insert(sub_extension.id.as_str()) {
|
||||
// extension ID already exists
|
||||
return Err(format!(
|
||||
"sub-extension with ID [{}] already exists",
|
||||
sub_extension.id
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// This checks the main extension only, it won't check sub-extensions.
|
||||
fn check_main_extension_only(extension: &Extension) -> Result<(), String> {
|
||||
// Group and Extension cannot have alias
|
||||
if extension.alias.is_some() {
|
||||
if extension.r#type == ExtensionType::Group || extension.r#type == ExtensionType::Extension
|
||||
{
|
||||
return Err(format!(
|
||||
"invalid extension [{}], extension of type [{:?}] cannot have alias",
|
||||
extension.id, extension.r#type
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Group and Extension cannot have hotkey
|
||||
if extension.hotkey.is_some() {
|
||||
if extension.r#type == ExtensionType::Group || extension.r#type == ExtensionType::Extension
|
||||
{
|
||||
return Err(format!(
|
||||
"invalid extension [{}], extension of type [{:?}] cannot have hotkey",
|
||||
extension.id, extension.r#type
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
return Err(format!(
|
||||
"invalid extension [{}], only extension of type [Group] and [Extension] can have sub-extensions",
|
||||
extension.id,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if extension.settings.is_some() {
|
||||
// Sub-extensions are all searchable, so this check is only for main extensions.
|
||||
if !extension.searchable() {
|
||||
return Err(format!(
|
||||
"invalid extension {}, field [settings] is currently only allowed in searchable extension, this type of extension is not searchable [{}]",
|
||||
extension.id, extension.r#type
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn check_sub_extension_only(
|
||||
extension_id: &str,
|
||||
sub_extension: &Extension,
|
||||
limited_platforms: Option<&HashSet<Platform>>,
|
||||
) -> Result<(), String> {
|
||||
if sub_extension.r#type == ExtensionType::Group
|
||||
|| sub_extension.r#type == ExtensionType::Extension
|
||||
{
|
||||
return Err(format!(
|
||||
"invalid sub-extension [{}-{}]: sub-extensions should not be of type [Group] or [Extension]",
|
||||
extension_id, sub_extension.id
|
||||
));
|
||||
}
|
||||
|
||||
if sub_extension.commands.is_some()
|
||||
|| sub_extension.scripts.is_some()
|
||||
|| sub_extension.quicklinks.is_some()
|
||||
|| sub_extension.views.is_some()
|
||||
{
|
||||
return Err(format!(
|
||||
"invalid sub-extension [{}-{}]: fields [commands/scripts/quicklinks/views] should not be set in sub-extensions",
|
||||
extension_id, sub_extension.id
|
||||
));
|
||||
}
|
||||
|
||||
if sub_extension.developer.is_some() {
|
||||
return Err(format!(
|
||||
"invalid sub-extension [{}-{}]: field [developer] should not be set in sub-extensions",
|
||||
extension_id, sub_extension.id
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(platforms_supported_by_main_extension) = limited_platforms {
|
||||
match sub_extension.platforms {
|
||||
Some(ref platforms_supported_by_sub_extension) => {
|
||||
let diff = platforms_supported_by_sub_extension
|
||||
.difference(&platforms_supported_by_main_extension)
|
||||
.into_iter()
|
||||
.map(|p| p.to_string())
|
||||
.collect::<Vec<String>>();
|
||||
|
||||
if !diff.is_empty() {
|
||||
return Err(format!(
|
||||
"invalid sub-extension [{}-{}]: it supports platforms {:?} that are not supported by the main extension",
|
||||
extension_id, sub_extension.id, diff
|
||||
));
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// if `sub_extension.platform` is None, it means it has the same value
|
||||
// as main extension's `platforms` field, so we don't need to check it.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn check_main_extension_or_sub_extension(
|
||||
extension: &Extension,
|
||||
identifier: &str,
|
||||
) -> Result<(), String> {
|
||||
// If field `action` is Some, then it should be a Command
|
||||
if extension.action.is_some() && extension.r#type != ExtensionType::Command {
|
||||
return Err(format!(
|
||||
"invalid {}, field [action] is set for a non-Command extension",
|
||||
identifier
|
||||
));
|
||||
}
|
||||
|
||||
if extension.r#type == ExtensionType::Command && extension.action.is_none() {
|
||||
return Err(format!(
|
||||
"invalid {}, field [action] should be set for a Command extension",
|
||||
identifier
|
||||
));
|
||||
}
|
||||
|
||||
// If field `quicklink` is Some, then it should be a Quicklink
|
||||
if extension.quicklink.is_some() && extension.r#type != ExtensionType::Quicklink {
|
||||
return Err(format!(
|
||||
"invalid {}, field [quicklink] is set for a non-Quicklink extension",
|
||||
identifier
|
||||
));
|
||||
}
|
||||
|
||||
if extension.r#type == ExtensionType::Quicklink && extension.quicklink.is_none() {
|
||||
return Err(format!(
|
||||
"invalid {}, field [quicklink] should be set for a Quicklink extension",
|
||||
identifier
|
||||
));
|
||||
}
|
||||
|
||||
// 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(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::extension::{
|
||||
CommandAction, ExtensionSettings, Quicklink, QuicklinkLink, QuicklinkLinkComponent,
|
||||
};
|
||||
|
||||
/// Helper function to create a basic valid 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 {
|
||||
id: id.to_string(),
|
||||
name: "Test Extension".to_string(),
|
||||
developer: None,
|
||||
platforms: None,
|
||||
description: "Test description".to_string(),
|
||||
icon: "test-icon.png".to_string(),
|
||||
r#type: extension_type,
|
||||
action: None,
|
||||
quicklink: None,
|
||||
commands: None,
|
||||
scripts: None,
|
||||
quicklinks: None,
|
||||
views: None,
|
||||
alias: None,
|
||||
hotkey: None,
|
||||
enabled: true,
|
||||
page,
|
||||
permission: None,
|
||||
settings: None,
|
||||
screenshots: None,
|
||||
url: None,
|
||||
version: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper function to create a command action
|
||||
fn create_command_action() -> CommandAction {
|
||||
CommandAction {
|
||||
exec: "echo".to_string(),
|
||||
args: Some(vec!["test".to_string()]),
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper function to create a quicklink
|
||||
fn create_quicklink() -> Quicklink {
|
||||
Quicklink {
|
||||
link: QuicklinkLink {
|
||||
components: vec![QuicklinkLinkComponent::StaticStr(
|
||||
"https://example.com".to_string(),
|
||||
)],
|
||||
},
|
||||
open_with: None,
|
||||
}
|
||||
}
|
||||
|
||||
/* test_check_main_extension_only */
|
||||
#[test]
|
||||
fn test_group_cannot_have_alias() {
|
||||
let mut extension = create_basic_extension("test-group", ExtensionType::Group);
|
||||
extension.alias = Some("group-alias".to_string());
|
||||
|
||||
let result = general_check(&extension);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("cannot have alias"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extension_cannot_have_alias() {
|
||||
let mut extension = create_basic_extension("test-ext", ExtensionType::Extension);
|
||||
extension.alias = Some("ext-alias".to_string());
|
||||
|
||||
let result = general_check(&extension);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("cannot have alias"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_group_cannot_have_hotkey() {
|
||||
let mut extension = create_basic_extension("test-group", ExtensionType::Group);
|
||||
extension.hotkey = Some("cmd+g".to_string());
|
||||
|
||||
let result = general_check(&extension);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("cannot have hotkey"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extension_cannot_have_hotkey() {
|
||||
let mut extension = create_basic_extension("test-ext", ExtensionType::Extension);
|
||||
extension.hotkey = Some("cmd+e".to_string());
|
||||
|
||||
let result = general_check(&extension);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("cannot have hotkey"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_non_container_types_cannot_have_sub_extensions() {
|
||||
let mut extension = create_basic_extension("test-cmd", ExtensionType::Command);
|
||||
extension.action = Some(create_command_action());
|
||||
extension.commands = Some(vec![create_basic_extension(
|
||||
"sub-cmd",
|
||||
ExtensionType::Command,
|
||||
)]);
|
||||
|
||||
let result = general_check(&extension);
|
||||
assert!(result.is_err());
|
||||
assert!(
|
||||
result
|
||||
.unwrap_err()
|
||||
.contains("only extension of type [Group] and [Extension] can have sub-extensions")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_non_searchable_extension_set_field_settings() {
|
||||
let mut extension = create_basic_extension("test-group", ExtensionType::Group);
|
||||
extension.settings = Some(ExtensionSettings {
|
||||
hide_before_open: None,
|
||||
});
|
||||
let error_msg = general_check(&extension).unwrap_err();
|
||||
assert!(
|
||||
error_msg
|
||||
.contains("field [settings] is currently only allowed in searchable extension")
|
||||
);
|
||||
|
||||
let mut extension = create_basic_extension("test-extension", ExtensionType::Extension);
|
||||
extension.settings = Some(ExtensionSettings {
|
||||
hide_before_open: None,
|
||||
});
|
||||
let error_msg = general_check(&extension).unwrap_err();
|
||||
assert!(
|
||||
error_msg
|
||||
.contains("field [settings] is currently only allowed in searchable extension")
|
||||
);
|
||||
}
|
||||
/* test_check_main_extension_only */
|
||||
|
||||
/* test check_main_extension_or_sub_extension */
|
||||
#[test]
|
||||
fn test_command_must_have_action() {
|
||||
let extension = create_basic_extension("test-cmd", ExtensionType::Command);
|
||||
|
||||
let result = general_check(&extension);
|
||||
assert!(result.is_err());
|
||||
assert!(
|
||||
result
|
||||
.unwrap_err()
|
||||
.contains("field [action] should be set for a Command extension")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_non_command_cannot_have_action() {
|
||||
let mut extension = create_basic_extension("test-script", ExtensionType::Script);
|
||||
extension.action = Some(create_command_action());
|
||||
|
||||
let result = general_check(&extension);
|
||||
assert!(result.is_err());
|
||||
assert!(
|
||||
result
|
||||
.unwrap_err()
|
||||
.contains("field [action] is set for a non-Command extension")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_quicklink_must_have_quicklink_field() {
|
||||
let extension = create_basic_extension("test-quicklink", ExtensionType::Quicklink);
|
||||
|
||||
let result = general_check(&extension);
|
||||
assert!(result.is_err());
|
||||
assert!(
|
||||
result
|
||||
.unwrap_err()
|
||||
.contains("field [quicklink] should be set for a Quicklink extension")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_non_quicklink_cannot_have_quicklink_field() {
|
||||
let mut extension = create_basic_extension("test-cmd", ExtensionType::Command);
|
||||
extension.action = Some(create_command_action());
|
||||
extension.quicklink = Some(create_quicklink());
|
||||
|
||||
let result = general_check(&extension);
|
||||
assert!(result.is_err());
|
||||
assert!(
|
||||
result
|
||||
.unwrap_err()
|
||||
.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_sub_extension_only */
|
||||
#[test]
|
||||
fn test_sub_extension_cannot_be_group() {
|
||||
let mut extension = create_basic_extension("test-group", ExtensionType::Group);
|
||||
let sub_group = create_basic_extension("sub-group", ExtensionType::Group);
|
||||
extension.commands = Some(vec![sub_group]);
|
||||
|
||||
let result = general_check(&extension);
|
||||
assert!(result.is_err());
|
||||
assert!(
|
||||
result
|
||||
.unwrap_err()
|
||||
.contains("sub-extensions should not be of type [Group] or [Extension]")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sub_extension_cannot_be_extension() {
|
||||
let mut extension = create_basic_extension("test-ext", ExtensionType::Extension);
|
||||
let sub_ext = create_basic_extension("sub-ext", ExtensionType::Extension);
|
||||
extension.scripts = Some(vec![sub_ext]);
|
||||
|
||||
let result = general_check(&extension);
|
||||
assert!(result.is_err());
|
||||
assert!(
|
||||
result
|
||||
.unwrap_err()
|
||||
.contains("sub-extensions should not be of type [Group] or [Extension]")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sub_extension_cannot_have_developer() {
|
||||
let mut extension = create_basic_extension("test-group", ExtensionType::Group);
|
||||
let mut sub_cmd = create_basic_extension("sub-cmd", ExtensionType::Command);
|
||||
sub_cmd.action = Some(create_command_action());
|
||||
sub_cmd.developer = Some("test-dev".to_string());
|
||||
|
||||
extension.commands = Some(vec![sub_cmd]);
|
||||
|
||||
let result = general_check(&extension);
|
||||
assert!(result.is_err());
|
||||
assert!(
|
||||
result
|
||||
.unwrap_err()
|
||||
.contains("field [developer] should not be set in sub-extensions")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sub_extension_cannot_have_sub_extensions() {
|
||||
let mut extension = create_basic_extension("test-group", ExtensionType::Group);
|
||||
let mut sub_cmd = create_basic_extension("sub-cmd", ExtensionType::Command);
|
||||
sub_cmd.action = Some(create_command_action());
|
||||
sub_cmd.commands = Some(vec![create_basic_extension(
|
||||
"nested-cmd",
|
||||
ExtensionType::Command,
|
||||
)]);
|
||||
|
||||
extension.commands = Some(vec![sub_cmd]);
|
||||
|
||||
let result = general_check(&extension);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains(
|
||||
"fields [commands/scripts/quicklinks/views] should not be set in sub-extensions"
|
||||
));
|
||||
}
|
||||
/* Test check_sub_extension_only */
|
||||
|
||||
#[test]
|
||||
fn test_duplicate_sub_extension_ids() {
|
||||
let mut extension = create_basic_extension("test-group", ExtensionType::Group);
|
||||
|
||||
let mut cmd1 = create_basic_extension("duplicate-id", ExtensionType::Command);
|
||||
cmd1.action = Some(create_command_action());
|
||||
|
||||
let mut cmd2 = create_basic_extension("duplicate-id", ExtensionType::Command);
|
||||
cmd2.action = Some(create_command_action());
|
||||
|
||||
extension.commands = Some(vec![cmd1, cmd2]);
|
||||
|
||||
let result = general_check(&extension);
|
||||
assert!(result.is_err());
|
||||
assert!(
|
||||
result
|
||||
.unwrap_err()
|
||||
.contains("sub-extension with ID [duplicate-id] already exists")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_duplicate_ids_across_different_sub_extension_types() {
|
||||
let mut extension = create_basic_extension("test-group", ExtensionType::Group);
|
||||
|
||||
let mut cmd = create_basic_extension("same-id", ExtensionType::Command);
|
||||
cmd.action = Some(create_command_action());
|
||||
|
||||
let script = create_basic_extension("same-id", ExtensionType::Script);
|
||||
|
||||
extension.commands = Some(vec![cmd]);
|
||||
extension.scripts = Some(vec![script]);
|
||||
|
||||
let result = general_check(&extension);
|
||||
assert!(result.is_err());
|
||||
assert!(
|
||||
result
|
||||
.unwrap_err()
|
||||
.contains("sub-extension with ID [same-id] already exists")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_valid_group_extension() {
|
||||
let mut extension = create_basic_extension("test-group", ExtensionType::Group);
|
||||
extension.commands = Some(vec![create_basic_extension("cmd1", ExtensionType::Command)]);
|
||||
|
||||
assert!(general_check(&extension).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_valid_extension_type() {
|
||||
let mut extension = create_basic_extension("test-ext", ExtensionType::Extension);
|
||||
extension.scripts = Some(vec![create_basic_extension(
|
||||
"script1",
|
||||
ExtensionType::Script,
|
||||
)]);
|
||||
|
||||
assert!(general_check(&extension).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_valid_command_extension() {
|
||||
let mut extension = create_basic_extension("test-cmd", ExtensionType::Command);
|
||||
extension.action = Some(create_command_action());
|
||||
|
||||
assert!(general_check(&extension).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_valid_quicklink_extension() {
|
||||
let mut extension = create_basic_extension("test-quicklink", ExtensionType::Quicklink);
|
||||
extension.quicklink = Some(create_quicklink());
|
||||
|
||||
assert!(general_check(&extension).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_valid_complex_extension() {
|
||||
let mut extension = create_basic_extension("spotify-controls", ExtensionType::Extension);
|
||||
|
||||
// Add valid commands
|
||||
let mut play_pause = create_basic_extension("play-pause", ExtensionType::Command);
|
||||
play_pause.action = Some(create_command_action());
|
||||
|
||||
let mut next_track = create_basic_extension("next-track", ExtensionType::Command);
|
||||
next_track.action = Some(create_command_action());
|
||||
|
||||
let mut prev_track = create_basic_extension("prev-track", ExtensionType::Command);
|
||||
prev_track.action = Some(create_command_action());
|
||||
|
||||
extension.commands = Some(vec![play_pause, next_track, prev_track]);
|
||||
|
||||
assert!(general_check(&extension).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_valid_single_layer_command() {
|
||||
let mut extension = create_basic_extension("empty-trash", ExtensionType::Command);
|
||||
extension.action = Some(create_command_action());
|
||||
|
||||
assert!(general_check(&extension).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_command_alias_and_hotkey_allowed() {
|
||||
let mut extension = create_basic_extension("test-cmd", ExtensionType::Command);
|
||||
extension.action = Some(create_command_action());
|
||||
extension.alias = Some("cmd-alias".to_string());
|
||||
extension.hotkey = Some("cmd+t".to_string());
|
||||
|
||||
assert!(general_check(&extension).is_ok());
|
||||
}
|
||||
|
||||
/*
|
||||
* Tests for check that sub extension cannot support extensions that are not
|
||||
* supported by the main extension
|
||||
*
|
||||
* Start here
|
||||
*/
|
||||
#[test]
|
||||
fn test_platform_validation_both_none() {
|
||||
// Case 1: main extension's platforms = None, sub extension's platforms = None
|
||||
// Should return Ok(())
|
||||
let mut main_extension = create_basic_extension("main-ext", ExtensionType::Group);
|
||||
main_extension.platforms = None;
|
||||
|
||||
let mut sub_cmd = create_basic_extension("sub-cmd", ExtensionType::Command);
|
||||
sub_cmd.action = Some(create_command_action());
|
||||
sub_cmd.platforms = None;
|
||||
|
||||
main_extension.commands = Some(vec![sub_cmd]);
|
||||
|
||||
let result = general_check(&main_extension);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_platform_validation_main_all_sub_none() {
|
||||
// Case 2: main extension's platforms = Some(all platforms), sub extension's platforms = None
|
||||
// Should return Ok(())
|
||||
let mut main_extension = create_basic_extension("main-ext", ExtensionType::Group);
|
||||
main_extension.platforms = Some(Platform::all());
|
||||
|
||||
let mut sub_cmd = create_basic_extension("sub-cmd", ExtensionType::Command);
|
||||
sub_cmd.action = Some(create_command_action());
|
||||
sub_cmd.platforms = None;
|
||||
|
||||
main_extension.commands = Some(vec![sub_cmd]);
|
||||
|
||||
let result = general_check(&main_extension);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_platform_validation_main_none_sub_some() {
|
||||
// Case 3: main extension's platforms = None, sub extension's platforms = Some([Platform::Macos])
|
||||
// Should return Ok(()) because None means supports all platforms
|
||||
let mut main_extension = create_basic_extension("main-ext", ExtensionType::Group);
|
||||
main_extension.platforms = None;
|
||||
|
||||
let mut sub_cmd = create_basic_extension("sub-cmd", ExtensionType::Command);
|
||||
sub_cmd.action = Some(create_command_action());
|
||||
sub_cmd.platforms = Some(HashSet::from([Platform::Macos]));
|
||||
|
||||
main_extension.commands = Some(vec![sub_cmd]);
|
||||
|
||||
let result = general_check(&main_extension);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_platform_validation_main_all_sub_subset() {
|
||||
// Case 4: main extension's platforms = Some(all platforms), sub extension's platforms = Some([Platform::Macos])
|
||||
// Should return Ok(()) because sub extension supports a subset of main extension's platforms
|
||||
let mut main_extension = create_basic_extension("main-ext", ExtensionType::Group);
|
||||
main_extension.platforms = Some(Platform::all());
|
||||
|
||||
let mut sub_cmd = create_basic_extension("sub-cmd", ExtensionType::Command);
|
||||
sub_cmd.action = Some(create_command_action());
|
||||
sub_cmd.platforms = Some(HashSet::from([Platform::Macos]));
|
||||
|
||||
main_extension.commands = Some(vec![sub_cmd]);
|
||||
|
||||
let result = general_check(&main_extension);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_platform_validation_main_limited_sub_unsupported() {
|
||||
// Case 5: main extension's platforms = Some([Platform::Macos]), sub extension's platforms = Some([Platform::Linux])
|
||||
// Should return Err because sub extension supports a platform not supported by main extension
|
||||
let mut main_extension = create_basic_extension("main-ext", ExtensionType::Group);
|
||||
main_extension.platforms = Some(HashSet::from([Platform::Macos]));
|
||||
|
||||
let mut sub_cmd = create_basic_extension("sub-cmd", ExtensionType::Command);
|
||||
sub_cmd.action = Some(create_command_action());
|
||||
sub_cmd.platforms = Some(HashSet::from([Platform::Linux]));
|
||||
|
||||
main_extension.commands = Some(vec![sub_cmd]);
|
||||
|
||||
let result = general_check(&main_extension);
|
||||
assert!(result.is_err());
|
||||
let error_msg = result.unwrap_err();
|
||||
assert!(error_msg.contains("it supports platforms"));
|
||||
assert!(error_msg.contains("that are not supported by the main extension"));
|
||||
assert!(error_msg.contains("Linux")); // Should mention the unsupported platform
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_platform_validation_main_partial_sub_unsupported() {
|
||||
// Case 6: main extension's platforms = Some([Platform::Macos, Platform::Windows]), sub extension's platforms = Some([Platform::Linux])
|
||||
// Should return Err because sub extension supports a platform not supported by main extension
|
||||
let mut main_extension = create_basic_extension("main-ext", ExtensionType::Group);
|
||||
main_extension.platforms = Some(HashSet::from([Platform::Macos, Platform::Windows]));
|
||||
|
||||
let mut sub_cmd = create_basic_extension("sub-cmd", ExtensionType::Command);
|
||||
sub_cmd.action = Some(create_command_action());
|
||||
sub_cmd.platforms = Some(HashSet::from([Platform::Linux]));
|
||||
|
||||
main_extension.commands = Some(vec![sub_cmd]);
|
||||
|
||||
let result = general_check(&main_extension);
|
||||
assert!(result.is_err());
|
||||
let error_msg = result.unwrap_err();
|
||||
assert!(error_msg.contains("it supports platforms"));
|
||||
assert!(error_msg.contains("that are not supported by the main extension"));
|
||||
assert!(error_msg.contains("Linux")); // Should mention the unsupported platform
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_platform_validation_main_limited_sub_none() {
|
||||
// Case 7: main extension's platforms = Some([Platform::Macos]), sub extension's platforms = None
|
||||
// Should return Ok(()) because when sub extension's platforms is None, it inherits main extension's platforms
|
||||
let mut main_extension = create_basic_extension("main-ext", ExtensionType::Group);
|
||||
main_extension.platforms = Some(HashSet::from([Platform::Macos]));
|
||||
|
||||
let mut sub_cmd = create_basic_extension("sub-cmd", ExtensionType::Command);
|
||||
sub_cmd.action = Some(create_command_action());
|
||||
sub_cmd.platforms = None;
|
||||
|
||||
main_extension.commands = Some(vec![sub_cmd]);
|
||||
|
||||
let result = general_check(&main_extension);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
/*
|
||||
* Tests for check that sub extension cannot support extensions that are not
|
||||
* supported by the main extension
|
||||
*
|
||||
* End here
|
||||
*/
|
||||
}
|
||||
303
src-tauri/src/extension/third_party/install/local_extension.rs
vendored
Normal file
303
src-tauri/src/extension/third_party/install/local_extension.rs
vendored
Normal file
@@ -0,0 +1,303 @@
|
||||
use crate::extension::third_party::check::general_check;
|
||||
use crate::extension::third_party::install::{
|
||||
convert_page, filter_out_incompatible_sub_extensions, is_extension_installed,
|
||||
};
|
||||
use crate::extension::third_party::{
|
||||
THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE, get_third_party_extension_directory,
|
||||
};
|
||||
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 serde_json::Value as Json;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use tauri::AppHandle;
|
||||
use tokio::fs;
|
||||
|
||||
/// All the extensions installed from local file will belong to a special developer
|
||||
/// "__local__".
|
||||
const DEVELOPER_ID_LOCAL: &str = "__local__";
|
||||
|
||||
/// Install the extension specified by `path`.
|
||||
///
|
||||
/// `path` should point to a directory with the following structure:
|
||||
///
|
||||
/// ```text
|
||||
/// extension-directory/
|
||||
/// ├── assets/
|
||||
/// │ ├── icon.png
|
||||
/// │ └── other-assets...
|
||||
/// └── plugin.json
|
||||
/// ```
|
||||
#[tauri::command]
|
||||
pub(crate) async fn install_local_extension(
|
||||
tauri_app_handle: AppHandle,
|
||||
path: PathBuf,
|
||||
) -> Result<(), String> {
|
||||
let extension_dir_name = path
|
||||
.file_name()
|
||||
.ok_or_else(|| "Invalid extension: no directory name".to_string())?
|
||||
.to_str()
|
||||
.ok_or_else(|| "Invalid extension: non-UTF8 extension id".to_string())?;
|
||||
|
||||
// we use extension directory name as the extension ID.
|
||||
let extension_id = extension_dir_name;
|
||||
if is_extension_installed(DEVELOPER_ID_LOCAL, extension_id).await {
|
||||
// The frontend code uses this string to distinguish between 2 error cases:
|
||||
//
|
||||
// 1. This extension is already imported
|
||||
// 2. This extension is incompatible with the current platform
|
||||
// 3. The selected directory does not contain a valid extension
|
||||
//
|
||||
// do NOT edit this without updating the frontend code.
|
||||
//
|
||||
// ```ts
|
||||
// if (errorMessage === "already imported") {
|
||||
// addError(t("settings.extensions.hints.extensionAlreadyImported"));
|
||||
// } else if (errorMessage === "incompatible") {
|
||||
// addError(t("settings.extensions.hints.incompatibleExtension"));
|
||||
// } else {
|
||||
// addError(t("settings.extensions.hints.importFailed"));
|
||||
// }
|
||||
// ```
|
||||
//
|
||||
// This is definitely error-prone, but we have to do this until we have
|
||||
// structured error type
|
||||
return Err("already imported".into());
|
||||
}
|
||||
|
||||
let plugin_json_path = path.join(PLUGIN_JSON_FILE_NAME);
|
||||
|
||||
let plugin_json_content = fs::read_to_string(&plugin_json_path)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Parse as JSON first as it is not valid for `struct Extension`, we need to
|
||||
// correct it (set fields `id` and `developer`) before converting it to `struct Extension`:
|
||||
let mut extension_json: Json =
|
||||
serde_json::from_str(&plugin_json_content).map_err(|e| e.to_string())?;
|
||||
|
||||
// Set the main extension ID to the directory name
|
||||
let extension_obj = extension_json
|
||||
.as_object_mut()
|
||||
.expect("extension_json should be an object");
|
||||
extension_obj.insert("id".to_string(), Json::String(extension_id.to_string()));
|
||||
extension_obj.insert(
|
||||
"developer".to_string(),
|
||||
Json::String(DEVELOPER_ID_LOCAL.to_string()),
|
||||
);
|
||||
|
||||
// Counter for sub-extension IDs
|
||||
let mut counter = 1u32;
|
||||
|
||||
// Set IDs for commands
|
||||
if let Some(commands) = extension_obj.get_mut("commands") {
|
||||
if let Some(commands_array) = commands.as_array_mut() {
|
||||
for command in commands_array {
|
||||
if let Some(command_obj) = command.as_object_mut() {
|
||||
command_obj.insert("id".to_string(), Json::String(counter.to_string()));
|
||||
counter += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set IDs for quicklinks
|
||||
if let Some(quicklinks) = extension_obj.get_mut("quicklinks") {
|
||||
if let Some(quicklinks_array) = quicklinks.as_array_mut() {
|
||||
for quicklink in quicklinks_array {
|
||||
if let Some(quicklink_obj) = quicklink.as_object_mut() {
|
||||
quicklink_obj.insert("id".to_string(), Json::String(counter.to_string()));
|
||||
counter += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set IDs for scripts
|
||||
if let Some(scripts) = extension_obj.get_mut("scripts") {
|
||||
if let Some(scripts_array) = scripts.as_array_mut() {
|
||||
for script in scripts_array {
|
||||
if let Some(script_obj) = script.as_object_mut() {
|
||||
script_obj.insert("id".to_string(), Json::String(counter.to_string()));
|
||||
counter += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Now we can convert JSON to `struct Extension`
|
||||
let mut extension: Extension =
|
||||
serde_json::from_value(extension_json).map_err(|e| e.to_string())?;
|
||||
|
||||
let current_platform = Platform::current();
|
||||
/* Check begins here */
|
||||
general_check(&extension)?;
|
||||
|
||||
if let Some(ref platforms) = extension.platforms {
|
||||
if !platforms.contains(¤t_platform) {
|
||||
// The frontend code uses this string to distinguish between 3 error cases:
|
||||
//
|
||||
// 1. This extension is already imported
|
||||
// 2. This extension is incompatible with the current platform
|
||||
// 3. The selected directory does not contain a valid extension
|
||||
//
|
||||
// do NOT edit this without updating the frontend code.
|
||||
//
|
||||
// ```ts
|
||||
// if (errorMessage === "already imported") {
|
||||
// addError(t("settings.extensions.hints.extensionAlreadyImported"));
|
||||
// } else if (errorMessage === "incompatible") {
|
||||
// addError(t("settings.extensions.hints.incompatibleExtension"));
|
||||
// } else {
|
||||
// addError(t("settings.extensions.hints.importFailed"));
|
||||
// }
|
||||
// ```
|
||||
//
|
||||
// This is definitely error-prone, but we have to do this until we have
|
||||
// structured error type
|
||||
return Err("incompatible".into());
|
||||
}
|
||||
}
|
||||
/* Check ends here */
|
||||
|
||||
// Extension is compatible with current platform, but it could contain sub
|
||||
// extensions that are not, filter them out.
|
||||
filter_out_incompatible_sub_extensions(&mut extension, current_platform);
|
||||
|
||||
// We are going to modify our third-party extension list, grab the write lock
|
||||
// to ensure exclusive access.
|
||||
let mut third_party_ext_list_write_lock = THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE
|
||||
.get()
|
||||
.expect("global third party search source not set")
|
||||
.write_lock()
|
||||
.await;
|
||||
|
||||
// Create destination directory
|
||||
let dest_dir = get_third_party_extension_directory(&tauri_app_handle)
|
||||
.join(DEVELOPER_ID_LOCAL)
|
||||
.join(extension_dir_name);
|
||||
|
||||
fs::create_dir_all(&dest_dir)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Copy all files except plugin.json
|
||||
let mut entries = fs::read_dir(&path).await.map_err(|e| e.to_string())?;
|
||||
|
||||
while let Some(entry) = entries.next_entry().await.map_err(|e| e.to_string())? {
|
||||
let file_name = entry.file_name();
|
||||
let file_name_str = file_name
|
||||
.to_str()
|
||||
.ok_or_else(|| "Invalid filename: non-UTF8".to_string())?;
|
||||
|
||||
// plugin.json will be handled separately.
|
||||
if file_name_str == PLUGIN_JSON_FILE_NAME {
|
||||
continue;
|
||||
}
|
||||
|
||||
let src_path = entry.path();
|
||||
let dest_path = dest_dir.join(&file_name);
|
||||
|
||||
if src_path.is_dir() {
|
||||
// Recursively copy directory
|
||||
copy_dir_recursively(&src_path, &dest_path).await?;
|
||||
} else {
|
||||
// Copy file
|
||||
fs::copy(&src_path, &dest_path)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
}
|
||||
|
||||
// Write the corrected plugin.json file
|
||||
let corrected_plugin_json =
|
||||
serde_json::to_string_pretty(&extension).map_err(|e| e.to_string())?;
|
||||
|
||||
let dest_plugin_json_path = dest_dir.join(PLUGIN_JSON_FILE_NAME);
|
||||
fs::write(&dest_plugin_json_path, corrected_plugin_json)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
/*
|
||||
* 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_page_path(&dest_dir, &mut extension)?;
|
||||
|
||||
// Add extension to the search source
|
||||
third_party_ext_list_write_lock.push(extension);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Helper function to recursively copy directories.
|
||||
#[async_recursion::async_recursion]
|
||||
async fn copy_dir_recursively(src: &Path, dest: &Path) -> Result<(), String> {
|
||||
tokio::fs::create_dir_all(dest)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut read_dir = tokio::fs::read_dir(src).await.map_err(|e| e.to_string())?;
|
||||
|
||||
while let Some(entry) = read_dir.next_entry().await.map_err(|e| e.to_string())? {
|
||||
let src_path = entry.path();
|
||||
let dest_path = dest.join(entry.file_name());
|
||||
|
||||
if src_path.is_dir() {
|
||||
copy_dir_recursively(&src_path, &dest_path).await?;
|
||||
} else {
|
||||
tokio::fs::copy(&src_path, &dest_path)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
684
src-tauri/src/extension/third_party/install/mod.rs
vendored
Normal file
684
src-tauri/src/extension/third_party/install/mod.rs
vendored
Normal file
@@ -0,0 +1,684 @@
|
||||
//! This module contains the code of extension installation.
|
||||
//!
|
||||
//!
|
||||
//! # How
|
||||
//!
|
||||
//! Technically, installing an extension involves the following steps. The order
|
||||
//! may vary between implementations.
|
||||
//!
|
||||
//! 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
|
||||
//! * extension directory
|
||||
//! * assets directory
|
||||
//! * various assets files, e.g., "icon.png"
|
||||
//! * plugin.json file
|
||||
//! * View pages if exist
|
||||
//!
|
||||
//! 5. If this extension contains any View extensions, call `convert_page()`
|
||||
//! on them to make them loadable by Tauri/webview.
|
||||
//!
|
||||
//! See `convert_page()` for more info.
|
||||
//!
|
||||
//! 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 store;
|
||||
|
||||
use crate::extension::Extension;
|
||||
use crate::util::platform::Platform;
|
||||
use std::path::Path;
|
||||
|
||||
use super::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE;
|
||||
|
||||
pub(crate) async fn is_extension_installed(developer: &str, extension_id: &str) -> bool {
|
||||
THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE
|
||||
.get()
|
||||
.expect("global third party search source not set")
|
||||
.extension_exists(developer, extension_id)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Filters out sub-extensions that are not compatible with the current platform.
|
||||
///
|
||||
/// We make `current_platform` an argument so that this function is testable.
|
||||
pub(crate) fn filter_out_incompatible_sub_extensions(
|
||||
extension: &mut Extension,
|
||||
current_platform: Platform,
|
||||
) {
|
||||
// Only process extensions of type Group or Extension that can have sub-extensions
|
||||
if !extension.r#type.contains_sub_items() {
|
||||
return;
|
||||
}
|
||||
|
||||
// For main extensions, None means all.
|
||||
let main_extension_supported_platforms = extension.platforms.clone().unwrap_or(Platform::all());
|
||||
|
||||
// Filter commands
|
||||
if let Some(ref mut commands) = extension.commands {
|
||||
commands.retain(|sub_ext| {
|
||||
if let Some(ref platforms) = sub_ext.platforms {
|
||||
platforms.contains(¤t_platform)
|
||||
} else {
|
||||
main_extension_supported_platforms.contains(¤t_platform)
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Filter scripts
|
||||
if let Some(ref mut scripts) = extension.scripts {
|
||||
scripts.retain(|sub_ext| {
|
||||
if let Some(ref platforms) = sub_ext.platforms {
|
||||
platforms.contains(¤t_platform)
|
||||
} else {
|
||||
main_extension_supported_platforms.contains(¤t_platform)
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Filter quicklinks
|
||||
if let Some(ref mut quicklinks) = extension.quicklinks {
|
||||
quicklinks.retain(|sub_ext| {
|
||||
if let Some(ref platforms) = sub_ext.platforms {
|
||||
platforms.contains(¤t_platform)
|
||||
} else {
|
||||
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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::extension::ExtensionType;
|
||||
use std::collections::HashSet;
|
||||
|
||||
/// Helper function to create a basic extension for testing
|
||||
/// `filter_out_incompatible_sub_extensions`
|
||||
fn create_test_extension(
|
||||
extension_type: ExtensionType,
|
||||
platforms: Option<HashSet<Platform>>,
|
||||
) -> Extension {
|
||||
Extension {
|
||||
id: "ID".into(),
|
||||
name: "name".into(),
|
||||
developer: None,
|
||||
platforms,
|
||||
description: "Test extension".to_string(),
|
||||
icon: "test-icon".to_string(),
|
||||
r#type: extension_type,
|
||||
action: None,
|
||||
quicklink: None,
|
||||
commands: None,
|
||||
scripts: None,
|
||||
quicklinks: None,
|
||||
views: None,
|
||||
alias: None,
|
||||
hotkey: None,
|
||||
enabled: true,
|
||||
settings: None,
|
||||
page: None,
|
||||
permission: None,
|
||||
screenshots: None,
|
||||
url: None,
|
||||
version: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filter_out_incompatible_sub_extensions_filter_non_group_extension_unchanged() {
|
||||
// Command
|
||||
let mut extension = create_test_extension(ExtensionType::Command, None);
|
||||
let clone = extension.clone();
|
||||
filter_out_incompatible_sub_extensions(&mut extension, Platform::Linux);
|
||||
assert_eq!(extension, clone);
|
||||
|
||||
// Quicklink
|
||||
let mut extension = create_test_extension(ExtensionType::Quicklink, None);
|
||||
let clone = extension.clone();
|
||||
filter_out_incompatible_sub_extensions(&mut extension, Platform::Linux);
|
||||
assert_eq!(extension, clone);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filter_out_incompatible_sub_extensions() {
|
||||
let mut main_extension = create_test_extension(ExtensionType::Group, None);
|
||||
// init sub extensions, which are macOS-only
|
||||
let commands = vec![create_test_extension(
|
||||
ExtensionType::Command,
|
||||
Some(HashSet::from([Platform::Macos])),
|
||||
)];
|
||||
let quicklinks = vec![create_test_extension(
|
||||
ExtensionType::Quicklink,
|
||||
Some(HashSet::from([Platform::Macos])),
|
||||
)];
|
||||
let scripts = vec![create_test_extension(
|
||||
ExtensionType::Script,
|
||||
Some(HashSet::from([Platform::Macos])),
|
||||
)];
|
||||
let views = vec![create_test_extension(
|
||||
ExtensionType::View,
|
||||
Some(HashSet::from([Platform::Macos])),
|
||||
)];
|
||||
// Set sub extensions
|
||||
main_extension.commands = Some(commands);
|
||||
main_extension.quicklinks = Some(quicklinks);
|
||||
main_extension.scripts = Some(scripts);
|
||||
main_extension.views = Some(views);
|
||||
|
||||
// Current platform is Linux, all the sub extensions should be filtered out.
|
||||
filter_out_incompatible_sub_extensions(&mut main_extension, Platform::Linux);
|
||||
|
||||
// assertions
|
||||
assert!(main_extension.commands.unwrap().is_empty());
|
||||
assert!(main_extension.quicklinks.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.
|
||||
#[test]
|
||||
fn test_filter_out_incompatible_sub_extensions_all_compatible() {
|
||||
{
|
||||
let mut main_extension = create_test_extension(ExtensionType::Group, None);
|
||||
// init sub extensions, which are compatible with all the platforms
|
||||
let commands = vec![create_test_extension(
|
||||
ExtensionType::Command,
|
||||
Some(Platform::all()),
|
||||
)];
|
||||
let quicklinks = vec![create_test_extension(
|
||||
ExtensionType::Quicklink,
|
||||
Some(Platform::all()),
|
||||
)];
|
||||
let scripts = vec![create_test_extension(
|
||||
ExtensionType::Script,
|
||||
Some(Platform::all()),
|
||||
)];
|
||||
let views = vec![create_test_extension(
|
||||
ExtensionType::View,
|
||||
Some(Platform::all()),
|
||||
)];
|
||||
// Set sub extensions
|
||||
main_extension.commands = Some(commands);
|
||||
main_extension.quicklinks = Some(quicklinks);
|
||||
main_extension.scripts = Some(scripts);
|
||||
main_extension.views = Some(views);
|
||||
|
||||
// Current platform is Linux, all the sub extensions should be filtered out.
|
||||
filter_out_incompatible_sub_extensions(&mut main_extension, Platform::Linux);
|
||||
|
||||
// assertions
|
||||
assert_eq!(main_extension.commands.unwrap().len(), 1);
|
||||
assert_eq!(main_extension.quicklinks.unwrap().len(), 1);
|
||||
assert_eq!(main_extension.scripts.unwrap().len(), 1);
|
||||
assert_eq!(main_extension.views.unwrap().len(), 1);
|
||||
}
|
||||
|
||||
// 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);
|
||||
// init sub extensions, which are compatible with all the platforms
|
||||
let commands = vec![create_test_extension(ExtensionType::Command, None)];
|
||||
let quicklinks = vec![create_test_extension(ExtensionType::Quicklink, None)];
|
||||
let scripts = vec![create_test_extension(ExtensionType::Script, None)];
|
||||
let views = vec![create_test_extension(ExtensionType::View, None)];
|
||||
// Set sub extensions
|
||||
main_extension.commands = Some(commands);
|
||||
main_extension.quicklinks = Some(quicklinks);
|
||||
main_extension.scripts = Some(scripts);
|
||||
main_extension.views = Some(views);
|
||||
|
||||
// Current platform is Linux, all the sub extensions should be filtered out.
|
||||
filter_out_incompatible_sub_extensions(&mut main_extension, Platform::Linux);
|
||||
|
||||
// assertions
|
||||
assert_eq!(main_extension.commands.unwrap().len(), 1);
|
||||
assert_eq!(main_extension.quicklinks.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);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Extension store related stuff.
|
||||
|
||||
use super::LOCAL_QUERY_SOURCE_TYPE;
|
||||
use super::super::LOCAL_QUERY_SOURCE_TYPE;
|
||||
use super::is_extension_installed;
|
||||
use crate::common::document::DataSourceReference;
|
||||
use crate::common::document::Document;
|
||||
use crate::common::error::SearchError;
|
||||
@@ -9,16 +10,24 @@ use crate::common::search::QuerySource;
|
||||
use crate::common::search::SearchQuery;
|
||||
use crate::common::traits::SearchSource;
|
||||
use crate::extension::Extension;
|
||||
use crate::extension::ExtensionType;
|
||||
use crate::extension::PLUGIN_JSON_FILE_NAME;
|
||||
use crate::extension::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE;
|
||||
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::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::server::http_client::HttpClient;
|
||||
use crate::util::platform::Platform;
|
||||
use async_trait::async_trait;
|
||||
use reqwest::StatusCode;
|
||||
use serde_json::Map as JsonObject;
|
||||
use serde_json::Value as Json;
|
||||
use std::io::Read;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use tauri::AppHandle;
|
||||
|
||||
const DATA_SOURCE_ID: &str = "Extension Store";
|
||||
@@ -152,14 +161,12 @@ pub(crate) async fn search_extension(
|
||||
.get("developer")
|
||||
.and_then(|dev| dev.get("id"))
|
||||
.and_then(|id| id.as_str())
|
||||
.expect("developer.id should exist")
|
||||
.to_string();
|
||||
.expect("developer.id should exist");
|
||||
|
||||
let extension_id = source_obj
|
||||
.get("id")
|
||||
.and_then(|id| id.as_str())
|
||||
.expect("extension id should exist")
|
||||
.to_string();
|
||||
.expect("extension id should exist");
|
||||
|
||||
let installed = is_extension_installed(developer_id, extension_id).await;
|
||||
source_obj.insert("installed".to_string(), Json::Bool(installed));
|
||||
@@ -170,12 +177,51 @@ pub(crate) async fn search_extension(
|
||||
Ok(extensions)
|
||||
}
|
||||
|
||||
async fn is_extension_installed(developer: String, extension_id: String) -> bool {
|
||||
THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE
|
||||
.get()
|
||||
.unwrap()
|
||||
.extension_exists(&developer, &extension_id)
|
||||
#[tauri::command]
|
||||
pub(crate) async fn extension_detail(
|
||||
id: String,
|
||||
) -> Result<Option<JsonObject<String, Json>>, String> {
|
||||
let path = format!("store/extension/{}", id);
|
||||
let response = HttpClient::get("default_coco_server", path.as_str(), None)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to send request: {:?}", e))?;
|
||||
|
||||
if response.status() == StatusCode::NOT_FOUND {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let response_dbg_str = format!("{:?}", response);
|
||||
// The response of an ES style GET request
|
||||
let mut response: JsonObject<String, Json> = response.json().await.unwrap_or_else(|_e| {
|
||||
panic!(
|
||||
"response body of [/store/extension/<ID>] is not a JSON object, response [{:?}]",
|
||||
response_dbg_str
|
||||
)
|
||||
});
|
||||
let source_json = response.remove("_source").unwrap_or_else(|| {
|
||||
panic!("field [_source] not found in the JSON returned from [/store/extension/<ID>]")
|
||||
});
|
||||
let mut source_obj = match source_json {
|
||||
Json::Object(obj) => obj,
|
||||
_ => panic!(
|
||||
"field [_source] should be a JSON object, but it is not, value: [{}]",
|
||||
source_json
|
||||
),
|
||||
};
|
||||
|
||||
let developer_id = match &source_obj["developer"]["id"] {
|
||||
Json::String(dev) => dev,
|
||||
_ => {
|
||||
panic!(
|
||||
"field [_source.developer.id] should be a string, but it is not, value: [{}]",
|
||||
source_obj["developer"]["id"]
|
||||
)
|
||||
}
|
||||
};
|
||||
let installed = is_extension_installed(developer_id, &id).await;
|
||||
source_obj.insert("installed".to_string(), Json::Bool(installed));
|
||||
|
||||
Ok(Some(source_obj))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -256,15 +302,40 @@ pub(crate) async fn install_extension_from_store(
|
||||
e
|
||||
);
|
||||
});
|
||||
let developer_id = extension.developer.clone().expect("developer has been set");
|
||||
|
||||
drop(plugin_json);
|
||||
|
||||
general_check(&extension)?;
|
||||
|
||||
let current_platform = Platform::current();
|
||||
if let Some(ref platforms) = extension.platforms {
|
||||
if !platforms.contains(¤t_platform) {
|
||||
return Err("this extension is not compatible with your OS".into());
|
||||
}
|
||||
}
|
||||
|
||||
if is_extension_installed(&developer_id, &id).await {
|
||||
return Err("Extension already installed.".into());
|
||||
}
|
||||
|
||||
// Extension is compatible with current platform, but it could contain sub
|
||||
// extensions that are not, filter them out.
|
||||
filter_out_incompatible_sub_extensions(&mut extension, current_platform);
|
||||
|
||||
// We are going to modify our third-party extension list, grab the write lock
|
||||
// to ensure exclusive access.
|
||||
let mut third_party_ext_list_write_lock = THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE
|
||||
.get()
|
||||
.expect("global third party search source not set")
|
||||
.write_lock()
|
||||
.await;
|
||||
|
||||
// Write extension files to the extension directory
|
||||
let developer = extension.developer.clone().unwrap_or_default();
|
||||
let extension_id = extension.id.clone();
|
||||
let extension_directory = {
|
||||
let mut path = get_third_party_extension_directory(&tauri_app_handle);
|
||||
path.push(developer);
|
||||
path.push(developer_id);
|
||||
path.push(extension_id.as_str());
|
||||
path
|
||||
};
|
||||
@@ -328,14 +399,58 @@ pub(crate) async fn install_extension_from_store(
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Turn it into an absolute path if it is a valid relative path because frontend code need this.
|
||||
canonicalize_relative_icon_path(&extension_directory, &mut extension)?;
|
||||
/*
|
||||
* 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()
|
||||
}
|
||||
}
|
||||
|
||||
THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE
|
||||
.get()
|
||||
.unwrap()
|
||||
.add_extension(extension)
|
||||
.await;
|
||||
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_page_path(&extension_directory, &mut extension)?;
|
||||
|
||||
third_party_ext_list_write_lock.push(extension);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
599
src-tauri/src/extension/third_party/mod.rs
vendored
599
src-tauri/src/extension/third_party/mod.rs
vendored
@@ -1,7 +1,7 @@
|
||||
pub(crate) mod store;
|
||||
pub(crate) mod check;
|
||||
pub(crate) mod install;
|
||||
|
||||
use super::Extension;
|
||||
use super::ExtensionType;
|
||||
use super::LOCAL_QUERY_SOURCE_TYPE;
|
||||
use super::PLUGIN_JSON_FILE_NAME;
|
||||
use super::alter_extension_json_file;
|
||||
@@ -15,18 +15,20 @@ use crate::common::search::QuerySource;
|
||||
use crate::common::search::SearchQuery;
|
||||
use crate::common::traits::SearchSource;
|
||||
use crate::extension::ExtensionBundleIdBorrowed;
|
||||
use crate::extension::calculate_text_similarity;
|
||||
use crate::extension::canonicalize_relative_page_path;
|
||||
use crate::util::platform::Platform;
|
||||
use async_trait::async_trait;
|
||||
use borrowme::ToOwned;
|
||||
use check::general_check;
|
||||
use function_name::named;
|
||||
use std::ffi::OsStr;
|
||||
use std::io::ErrorKind;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::OnceLock;
|
||||
use tauri::AppHandle;
|
||||
use tauri::Manager;
|
||||
use tauri::Runtime;
|
||||
use tauri::async_runtime;
|
||||
use tauri_plugin_global_shortcut::GlobalShortcutExt;
|
||||
use tauri_plugin_global_shortcut::ShortcutState;
|
||||
@@ -34,9 +36,7 @@ use tokio::fs::read_dir;
|
||||
use tokio::sync::RwLock;
|
||||
use tokio::sync::RwLockWriteGuard;
|
||||
|
||||
pub(crate) fn get_third_party_extension_directory<R: Runtime>(
|
||||
tauri_app_handle: &AppHandle<R>,
|
||||
) -> PathBuf {
|
||||
pub(crate) fn get_third_party_extension_directory(tauri_app_handle: &AppHandle) -> PathBuf {
|
||||
let mut app_data_dir = tauri_app_handle.path().app_data_dir().expect(
|
||||
"User home directory not found, which should be impossible on desktop environments",
|
||||
);
|
||||
@@ -45,11 +45,9 @@ pub(crate) fn get_third_party_extension_directory<R: Runtime>(
|
||||
app_data_dir
|
||||
}
|
||||
|
||||
pub(crate) async fn list_third_party_extensions(
|
||||
pub(crate) async fn load_third_party_extensions_from_directory(
|
||||
directory: &Path,
|
||||
) -> Result<(bool, Vec<Extension>), String> {
|
||||
let mut found_invalid_extensions = false;
|
||||
|
||||
) -> Result<Vec<Extension>, String> {
|
||||
let mut extensions_dir_iter = read_dir(&directory).await.map_err(|e| e.to_string())?;
|
||||
let current_platform = Platform::current();
|
||||
|
||||
@@ -65,7 +63,6 @@ pub(crate) async fn list_third_party_extensions(
|
||||
};
|
||||
let developer_dir_file_type = developer_dir.file_type().await.map_err(|e| e.to_string())?;
|
||||
if !developer_dir_file_type.is_dir() {
|
||||
found_invalid_extensions = true;
|
||||
log::warn!(
|
||||
"file [{}] under the third party extension directory should be a directory, but it is not",
|
||||
developer_dir.file_name().display()
|
||||
@@ -87,14 +84,17 @@ pub(crate) async fn list_third_party_extensions(
|
||||
let Some(extension_dir) = opt_extension_dir else {
|
||||
break 'extension;
|
||||
};
|
||||
let extension_dir_file_name = extension_dir
|
||||
.file_name()
|
||||
.into_string()
|
||||
.expect("extension directory name should be UTF-8 encoded");
|
||||
|
||||
let extension_dir_file_type =
|
||||
extension_dir.file_type().await.map_err(|e| e.to_string())?;
|
||||
if !extension_dir_file_type.is_dir() {
|
||||
found_invalid_extensions = true;
|
||||
log::warn!(
|
||||
"invalid extension [{}]: a valid extension should be a directory, but it is not",
|
||||
extension_dir.file_name().display()
|
||||
extension_dir_file_name
|
||||
);
|
||||
|
||||
// Skip invalid extension
|
||||
@@ -109,7 +109,6 @@ pub(crate) async fn list_third_party_extensions(
|
||||
};
|
||||
|
||||
if !plugin_json_file_path.is_file() {
|
||||
found_invalid_extensions = true;
|
||||
log::warn!(
|
||||
"invalid extension: [{}]: extension file [{}] should be a JSON file, but it is not",
|
||||
extension_dir.file_name().display(),
|
||||
@@ -126,10 +125,9 @@ pub(crate) async fn list_third_party_extensions(
|
||||
let mut extension = match serde_json::from_str::<Extension>(&plugin_json_file_content) {
|
||||
Ok(extension) => extension,
|
||||
Err(e) => {
|
||||
found_invalid_extensions = true;
|
||||
log::warn!(
|
||||
"invalid extension: [{}]: extension file [{}] is invalid, error: '{}'",
|
||||
extension_dir.file_name().display(),
|
||||
"invalid extension: [{}]: cannot parse file [{}] as a [struct Extension], error: '{}'",
|
||||
extension_dir_file_name,
|
||||
plugin_json_file_path.display(),
|
||||
e
|
||||
);
|
||||
@@ -137,20 +135,57 @@ pub(crate) async fn list_third_party_extensions(
|
||||
}
|
||||
};
|
||||
|
||||
// Turn it into an absolute path if it is a valid relative path because frontend code need this.
|
||||
canonicalize_relative_icon_path(&extension_dir.path(), &mut extension)?;
|
||||
/* Check starts here */
|
||||
if extension.id != extension_dir_file_name {
|
||||
log::warn!(
|
||||
"extension under [{}:{}] has an ID that is not same as the [{}]",
|
||||
developer_dir.file_name().display(),
|
||||
extension_dir_file_name,
|
||||
extension.id,
|
||||
);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extension should be unique
|
||||
if extensions.iter().any(|ext: &Extension| {
|
||||
ext.id == extension.id && ext.developer == extension.developer
|
||||
}) {
|
||||
log::warn!(
|
||||
"an extension with the same bundle ID [ID {}, developer {:?}] already exists, skip this one",
|
||||
extension.id,
|
||||
extension.developer
|
||||
);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Err(error_msg) = general_check(&extension) {
|
||||
log::warn!("{}", error_msg);
|
||||
|
||||
if !validate_extension(
|
||||
&extension,
|
||||
&extension_dir.file_name(),
|
||||
&extensions,
|
||||
current_platform,
|
||||
) {
|
||||
found_invalid_extensions = true;
|
||||
// Skip invalid extension
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(ref platforms) = extension.platforms {
|
||||
if !platforms.contains(¤t_platform) {
|
||||
log::warn!(
|
||||
"installed third-party extension [developer {}, ID {}] is not compatible with current platform, either user messes our directory or something wrong with our extension check",
|
||||
extension
|
||||
.developer
|
||||
.as_ref()
|
||||
.expect("third party extension should have [developer] set"),
|
||||
extension.id
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
/* Check ends here */
|
||||
|
||||
// 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_page_path(&extension_dir.path(), &mut extension)?;
|
||||
|
||||
extensions.push(extension);
|
||||
}
|
||||
}
|
||||
@@ -163,203 +198,7 @@ pub(crate) async fn list_third_party_extensions(
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
|
||||
Ok((found_invalid_extensions, extensions))
|
||||
}
|
||||
|
||||
/// Helper function to validate `extension`, return `true` if it is valid.
|
||||
fn validate_extension(
|
||||
extension: &Extension,
|
||||
extension_dir_name: &OsStr,
|
||||
listed_extensions: &[Extension],
|
||||
current_platform: Platform,
|
||||
) -> bool {
|
||||
if OsStr::new(&extension.id) != extension_dir_name {
|
||||
log::warn!(
|
||||
"invalid extension []: id [{}] and extension directory name [{}] do not match",
|
||||
extension.id,
|
||||
extension_dir_name.display()
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Extension ID should be unique
|
||||
if listed_extensions.iter().any(|ext| ext.id == extension.id) {
|
||||
log::warn!(
|
||||
"invalid extension []: extension with id [{}] already exists",
|
||||
extension.id,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
if !validate_extension_or_sub_item(extension) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Extension is incompatible
|
||||
if let Some(ref platforms) = extension.platforms {
|
||||
if !platforms.contains(¤t_platform) {
|
||||
log::warn!(
|
||||
"extension [{}] is not compatible with the current platform [{}], it is available to {:?}",
|
||||
extension.id,
|
||||
current_platform,
|
||||
platforms
|
||||
.iter()
|
||||
.map(|os| os.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref commands) = extension.commands {
|
||||
if !validate_sub_items(&extension.id, commands) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref scripts) = extension.scripts {
|
||||
if !validate_sub_items(&extension.id, scripts) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref quicklinks) = extension.quicklinks {
|
||||
if !validate_sub_items(&extension.id, quicklinks) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
/// Checks that can be performed against an extension or a sub item.
|
||||
fn validate_extension_or_sub_item(extension: &Extension) -> bool {
|
||||
// If field `action` is Some, then it should be a Command
|
||||
if extension.action.is_some() && extension.r#type != ExtensionType::Command {
|
||||
log::warn!(
|
||||
"invalid extension [{}], [action] is set for a non-Command extension",
|
||||
extension.id
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
if extension.r#type == ExtensionType::Command && extension.action.is_none() {
|
||||
log::warn!(
|
||||
"invalid extension [{}], [action] should be set for a Command extension",
|
||||
extension.id
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
// If field `quicklink` is Some, then it should be a Quicklink
|
||||
if extension.quicklink.is_some() && extension.r#type != ExtensionType::Quicklink {
|
||||
log::warn!(
|
||||
"invalid extension [{}], [quicklink] is set for a non-Quicklink extension",
|
||||
extension.id
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
if extension.r#type == ExtensionType::Quicklink && extension.quicklink.is_none() {
|
||||
log::warn!(
|
||||
"invalid extension [{}], [quicklink] should be set for a Quicklink extension",
|
||||
extension.id
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Group and Extension cannot have alias
|
||||
if extension.alias.is_some() {
|
||||
if extension.r#type == ExtensionType::Group || extension.r#type == ExtensionType::Extension
|
||||
{
|
||||
log::warn!(
|
||||
"invalid extension [{}], extension of type [{:?}] cannot have alias",
|
||||
extension.id,
|
||||
extension.r#type
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Group and Extension cannot have hotkey
|
||||
if extension.hotkey.is_some() {
|
||||
if extension.r#type == ExtensionType::Group || extension.r#type == ExtensionType::Extension
|
||||
{
|
||||
log::warn!(
|
||||
"invalid extension [{}], extension of type [{:?}] cannot have hotkey",
|
||||
extension.id,
|
||||
extension.r#type
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if extension.commands.is_some() || extension.scripts.is_some() || extension.quicklinks.is_some()
|
||||
{
|
||||
if extension.r#type != ExtensionType::Group && extension.r#type != ExtensionType::Extension
|
||||
{
|
||||
log::warn!(
|
||||
"invalid extension [{}], only extension of type [Group] and [Extension] can have sub-items",
|
||||
extension.id,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
/// Helper function to check sub-items.
|
||||
fn validate_sub_items(extension_id: &str, sub_items: &[Extension]) -> bool {
|
||||
for (sub_item_index, sub_item) in sub_items.iter().enumerate() {
|
||||
// If field `action` is Some, then it should be a Command
|
||||
if sub_item.action.is_some() && sub_item.r#type != ExtensionType::Command {
|
||||
log::warn!(
|
||||
"invalid extension sub-item [{}-{}]: [action] is set for a non-Command extension",
|
||||
extension_id,
|
||||
sub_item.id
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
if sub_item.r#type == ExtensionType::Group || sub_item.r#type == ExtensionType::Extension {
|
||||
log::warn!(
|
||||
"invalid extension sub-item [{}-{}]: sub-item should not be of type [Group] or [Extension]",
|
||||
extension_id,
|
||||
sub_item.id
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
let sub_item_with_same_id_count = sub_items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_idx, ext)| ext.id == sub_item.id)
|
||||
.filter(|(idx, _ext)| *idx != sub_item_index)
|
||||
.count();
|
||||
if sub_item_with_same_id_count != 0 {
|
||||
log::warn!(
|
||||
"invalid extension [{}]: found more than one sub-items with the same ID [{}]",
|
||||
extension_id,
|
||||
sub_item.id
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
if !validate_extension_or_sub_item(sub_item) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if sub_item.platforms.is_some() {
|
||||
log::warn!(
|
||||
"invalid extension [{}]: key [platforms] should not be set in sub-items",
|
||||
extension_id,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
Ok(extensions)
|
||||
}
|
||||
|
||||
/// All the third-party extensions will be registered as one search source.
|
||||
@@ -367,7 +206,7 @@ fn validate_sub_items(extension_id: &str, sub_items: &[Extension]) -> bool {
|
||||
/// Since some `#[tauri::command]`s need to access it, we store it in a global
|
||||
/// static variable as well.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(super) struct ThirdPartyExtensionsSearchSource {
|
||||
pub(crate) struct ThirdPartyExtensionsSearchSource {
|
||||
inner: Arc<ThirdPartyExtensionsSearchSourceInner>,
|
||||
}
|
||||
|
||||
@@ -419,7 +258,7 @@ impl ThirdPartyExtensionsSearchSource {
|
||||
|
||||
if event.state() == ShortcutState::Pressed {
|
||||
async_runtime::spawn(async move {
|
||||
let result = open(app_handle_clone, on_opened_clone).await;
|
||||
let result = open(app_handle_clone, on_opened_clone, None).await;
|
||||
if let Err(msg) = result {
|
||||
log::warn!(
|
||||
"failed to open extension [{}], error [{}]",
|
||||
@@ -453,6 +292,11 @@ impl ThirdPartyExtensionsSearchSource {
|
||||
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(())
|
||||
@@ -495,6 +339,11 @@ impl ThirdPartyExtensionsSearchSource {
|
||||
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(())
|
||||
@@ -508,6 +357,11 @@ impl ThirdPartyExtensionsSearchSource {
|
||||
}
|
||||
}
|
||||
|
||||
/// Acquire the write lock to the extension list.
|
||||
pub(crate) async fn write_lock(&self) -> RwLockWriteGuard<'_, Vec<Extension>> {
|
||||
self.inner.extensions.write().await
|
||||
}
|
||||
|
||||
#[named]
|
||||
pub(super) async fn enable_extension(
|
||||
&self,
|
||||
@@ -622,7 +476,7 @@ impl ThirdPartyExtensionsSearchSource {
|
||||
|
||||
/// Initialize the third-party extensions, which literally means
|
||||
/// enabling/activating the enabled extensions.
|
||||
pub(super) async fn init(&self, tauri_app_handle: &AppHandle) -> Result<(), String> {
|
||||
pub(crate) async fn init(&self, tauri_app_handle: &AppHandle) -> Result<(), String> {
|
||||
let extensions_read_lock = self.inner.extensions.read().await;
|
||||
|
||||
for extension in extensions_read_lock.iter().filter(|ext| ext.enabled) {
|
||||
@@ -680,7 +534,7 @@ impl ThirdPartyExtensionsSearchSource {
|
||||
|
||||
if event.state() == ShortcutState::Pressed {
|
||||
async_runtime::spawn(async move {
|
||||
let result = open(app_handle_clone, on_opened_clone).await;
|
||||
let result = open(app_handle_clone, on_opened_clone, None).await;
|
||||
if let Err(msg) = result {
|
||||
log::warn!(
|
||||
"failed to open extension [{:?}], error [{}]",
|
||||
@@ -793,48 +647,68 @@ impl ThirdPartyExtensionsSearchSource {
|
||||
.any(|ext| ext.developer.as_deref() == Some(developer) && ext.id == extension_id)
|
||||
}
|
||||
|
||||
/// Add `extension` to the **in-memory** extension list.
|
||||
pub(crate) async fn add_extension(&self, extension: Extension) {
|
||||
assert!(
|
||||
extension.developer.is_some(),
|
||||
"loaded third party extension should have its developer set"
|
||||
);
|
||||
pub(crate) async fn uninstall_extension(
|
||||
&self,
|
||||
tauri_app_handle: &AppHandle,
|
||||
developer: &str,
|
||||
extension_id: &str,
|
||||
) -> Result<(), String> {
|
||||
let mut write_lock = self.inner.extensions.write().await;
|
||||
|
||||
let mut write_lock_guard = self.inner.extensions.write().await;
|
||||
if write_lock_guard
|
||||
.iter()
|
||||
.any(|ext| ext.developer == extension.developer && ext.id == extension.id)
|
||||
{
|
||||
panic!(
|
||||
"extension [{}/{}] already installed",
|
||||
extension
|
||||
.developer
|
||||
.as_ref()
|
||||
.expect("just checked it is Some"),
|
||||
extension.id
|
||||
);
|
||||
}
|
||||
write_lock_guard.push(extension);
|
||||
}
|
||||
|
||||
/// Remove `extension` from the **in-memory** extension list.
|
||||
pub(crate) async fn remove_extension(&self, developer: &str, extension_id: &str) -> Extension {
|
||||
let mut write_lock_guard = self.inner.extensions.write().await;
|
||||
let Some(index) = write_lock_guard
|
||||
let Some(index) = write_lock
|
||||
.iter()
|
||||
.position(|ext| ext.developer.as_deref() == Some(developer) && ext.id == extension_id)
|
||||
else {
|
||||
panic!(
|
||||
"extension [{}/{}] not installed, but we are trying to remove it",
|
||||
return Err(format!(
|
||||
"The extension we are trying to uninstall [{}/{}] does not exist",
|
||||
developer, extension_id
|
||||
);
|
||||
));
|
||||
};
|
||||
let deleted_extension = write_lock.remove(index);
|
||||
|
||||
let extension_dir = {
|
||||
let mut path = get_third_party_extension_directory(&tauri_app_handle);
|
||||
path.push(developer);
|
||||
path.push(extension_id);
|
||||
|
||||
path
|
||||
};
|
||||
|
||||
write_lock_guard.remove(index)
|
||||
if let Err(e) = tokio::fs::remove_dir_all(extension_dir.as_path()).await {
|
||||
let error_kind = e.kind();
|
||||
if error_kind == ErrorKind::NotFound {
|
||||
// We accept this error because we do want it to not exist. But
|
||||
// since it is not a state we expect, throw a warning.
|
||||
log::warn!(
|
||||
"trying to uninstalling extension [developer {} id {}], but its directory does not exist",
|
||||
developer,
|
||||
extension_id
|
||||
);
|
||||
} else {
|
||||
return Err(format!(
|
||||
"failed to uninstall extension [developer {} id {}] due to error {}",
|
||||
developer, extension_id, e
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Unregister the extension hotkey, if set.
|
||||
//
|
||||
// Unregistering hotkey is the only thing that we will do when we disable
|
||||
// an extension, so we directly use this function here even though "disabling"
|
||||
// the extension that one is trying to uninstall does not make too much sense.
|
||||
Self::_disable_extension(&tauri_app_handle, &deleted_extension).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Take a point-in-time snapshot at the extension list and return it.
|
||||
pub(crate) async fn extensions_snapshot(&self) -> Vec<Extension> {
|
||||
self.inner.extensions.read().await.clone()
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) static THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE: OnceLock<ThirdPartyExtensionsSearchSource> =
|
||||
pub(crate) static THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE: OnceLock<ThirdPartyExtensionsSearchSource> =
|
||||
OnceLock::new();
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -914,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 {
|
||||
if let Some(hit) =
|
||||
extension_to_hit(extension, &query_lower, opt_data_source.as_deref())
|
||||
@@ -942,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,
|
||||
query_lower: &str,
|
||||
opt_data_source: Option<&str>,
|
||||
@@ -1011,183 +908,3 @@ fn extension_to_hit(
|
||||
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> {
|
||||
let extension_dir = {
|
||||
let mut path = get_third_party_extension_directory(&tauri_app_handle);
|
||||
path.push(developer.as_str());
|
||||
path.push(extension_id.as_str());
|
||||
|
||||
path
|
||||
};
|
||||
if !extension_dir.try_exists().map_err(|e| e.to_string())? {
|
||||
panic!(
|
||||
"we are uninstalling extension [{}/{}], but there is no such extension files on disk",
|
||||
developer, extension_id
|
||||
)
|
||||
}
|
||||
tokio::fs::remove_dir_all(extension_dir.as_path())
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let extension = THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE
|
||||
.get()
|
||||
.unwrap()
|
||||
.remove_extension(&developer, &extension_id)
|
||||
.await;
|
||||
|
||||
// Unregister the extension hotkey, if set.
|
||||
//
|
||||
// Unregistering hotkey is the only thing that we will do when we disable
|
||||
// an extension, so we directly use this function here even though "disabling"
|
||||
// the extension that one is trying to uninstall does not make too much sense.
|
||||
ThirdPartyExtensionsSearchSource::_disable_extension(&tauri_app_handle, &extension).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,16 +10,15 @@ mod shortcut;
|
||||
mod util;
|
||||
|
||||
use crate::common::register::SearchSourceRegistry;
|
||||
// use crate::common::traits::SearchSource;
|
||||
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 autostart::{change_autostart, ensure_autostart_state_consistent};
|
||||
use crate::util::prevent_default;
|
||||
use autostart::change_autostart;
|
||||
use lazy_static::lazy_static;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::OnceLock;
|
||||
use tauri::async_runtime::block_on;
|
||||
use tauri::plugin::TauriPlugin;
|
||||
use tauri::{AppHandle, Emitter, Manager, PhysicalPosition, Runtime, WebviewWindow, WindowEvent};
|
||||
use tauri::{AppHandle, Emitter, Manager, PhysicalPosition, WebviewWindow, WindowEvent};
|
||||
use tauri_plugin_autostart::MacosLauncher;
|
||||
|
||||
/// Tauri store name
|
||||
@@ -70,10 +69,8 @@ pub fn run() {
|
||||
|
||||
#[cfg(desktop)]
|
||||
{
|
||||
app_builder = app_builder.plugin(tauri_plugin_single_instance::init(|_app, argv, _cwd| {
|
||||
log::debug!("a new app instance was opened with {argv:?} and the deep link event was already triggered");
|
||||
// when defining deep link schemes at runtime, you must also check `argv` here
|
||||
}));
|
||||
app_builder =
|
||||
app_builder.plugin(tauri_plugin_single_instance::init(|_app, _argv, _cwd| {}));
|
||||
}
|
||||
|
||||
app_builder = app_builder
|
||||
@@ -96,7 +93,8 @@ pub fn run() {
|
||||
.build(),
|
||||
)
|
||||
.plugin(tauri_plugin_windows_version::init())
|
||||
.plugin(tauri_plugin_opener::init());
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(prevent_default::init());
|
||||
|
||||
// Conditional compilation for macOS
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -130,9 +128,7 @@ pub fn run() {
|
||||
server::connector::get_connectors_by_server,
|
||||
search::query_coco_fusion,
|
||||
assistant::chat_history,
|
||||
assistant::new_chat,
|
||||
assistant::chat_create,
|
||||
assistant::send_message,
|
||||
assistant::chat_chat,
|
||||
assistant::session_chat_history,
|
||||
assistant::open_session_chat,
|
||||
@@ -145,11 +141,9 @@ pub fn run() {
|
||||
assistant::assistant_get_multi,
|
||||
// server::get_coco_server_datasources,
|
||||
// server::get_coco_server_connectors,
|
||||
server::websocket::connect_to_server,
|
||||
server::websocket::disconnect,
|
||||
get_app_search_source,
|
||||
server::attachment::upload_attachment,
|
||||
server::attachment::get_attachment,
|
||||
server::attachment::get_attachment_by_ids,
|
||||
server::attachment::delete_attachment,
|
||||
server::transcription::transcription,
|
||||
server::system_settings::get_system_settings,
|
||||
@@ -159,6 +153,7 @@ pub fn run() {
|
||||
extension::built_in::application::add_app_search_path,
|
||||
extension::built_in::application::remove_app_search_path,
|
||||
extension::built_in::application::reindex_applications,
|
||||
extension::quicklink_link_arguments,
|
||||
extension::list_extensions,
|
||||
extension::enable_extension,
|
||||
extension::disable_extension,
|
||||
@@ -166,28 +161,26 @@ pub fn run() {
|
||||
extension::register_extension_hotkey,
|
||||
extension::unregister_extension_hotkey,
|
||||
extension::is_extension_enabled,
|
||||
extension::third_party::store::search_extension,
|
||||
extension::third_party::store::install_extension_from_store,
|
||||
extension::third_party::install::store::search_extension,
|
||||
extension::third_party::install::store::extension_detail,
|
||||
extension::third_party::install::store::install_extension_from_store,
|
||||
extension::third_party::install::local_extension::install_local_extension,
|
||||
extension::third_party::uninstall_extension,
|
||||
extension::api::apis,
|
||||
extension::api::fs::read_dir,
|
||||
settings::set_allow_self_signature,
|
||||
settings::get_allow_self_signature,
|
||||
assistant::ask_ai,
|
||||
crate::common::document::open,
|
||||
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
||||
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,
|
||||
server::synthesize::synthesize,
|
||||
util::file::get_file_icon,
|
||||
setup::backend_setup,
|
||||
util::app_lang::update_app_lang,
|
||||
util::path::path_absolute,
|
||||
])
|
||||
.setup(|app| {
|
||||
let app_handle = app.handle().clone();
|
||||
GLOBAL_TAURI_APP_HANDLE
|
||||
.set(app_handle.clone())
|
||||
.expect("global tauri AppHandle already initialized");
|
||||
log::trace!("global Tauri AppHandle set");
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
log::trace!("hiding Dock icon on macOS");
|
||||
@@ -195,68 +188,21 @@ pub fn run() {
|
||||
log::trace!("Dock icon should be hidden now");
|
||||
}
|
||||
|
||||
let registry = SearchSourceRegistry::default();
|
||||
|
||||
app.manage(registry); // Store registry in Tauri's app state
|
||||
app.manage(server::websocket::WebSocketManager::default());
|
||||
|
||||
// This has to be called before initializing extensions as doing that
|
||||
// requires access to the shortcut store, which will be set by this
|
||||
// function.
|
||||
shortcut::enable_shortcut(app);
|
||||
|
||||
block_on(async {
|
||||
init(app.handle()).await;
|
||||
|
||||
// We want all the extensions here, so no filter condition specified.
|
||||
match extension::list_extensions(app_handle.clone(), None, None, false).await {
|
||||
Ok((_found_invalid_extensions, extensions)) => {
|
||||
// Initializing extension relies on SearchSourceRegistry, so this should
|
||||
// be executed after `app.manage(registry)`
|
||||
if let Err(e) =
|
||||
extension::init_extensions(app_handle.clone(), extensions).await
|
||||
{
|
||||
log::error!("initializing extensions failed with error [{}]", e);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("listing extensions failed with error [{}]", e);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ensure_autostart_state_consistent(app)?;
|
||||
|
||||
// app.listen("theme-changed", move |event| {
|
||||
// if let Ok(payload) = serde_json::from_str::<ThemeChangedPayload>(event.payload()) {
|
||||
// // switch_tray_icon(app.app_handle(), payload.is_dark_mode);
|
||||
// log::debug!("Theme changed: is_dark_mode = {}", payload.is_dark_mode);
|
||||
// }
|
||||
// });
|
||||
|
||||
#[cfg(desktop)]
|
||||
{
|
||||
#[cfg(any(windows, target_os = "linux"))]
|
||||
{
|
||||
app.deep_link().register("coco")?;
|
||||
use tauri_plugin_deep_link::DeepLinkExt;
|
||||
app.deep_link().register_all()?;
|
||||
}
|
||||
}
|
||||
|
||||
// app.deep_link().on_open_url(|event| {
|
||||
// dbg!(event.urls());
|
||||
// });
|
||||
|
||||
let main_window = app.get_webview_window(MAIN_WINDOW_LABEL).unwrap();
|
||||
let settings_window = app.get_webview_window(SETTINGS_WINDOW_LABEL).unwrap();
|
||||
let check_window = app.get_webview_window(CHECK_WINDOW_LABEL).unwrap();
|
||||
/* ----------- This code must be executed on the main thread and must not be relocated. ----------- */
|
||||
let app_handle = app.app_handle();
|
||||
let main_window = app_handle.get_webview_window(MAIN_WINDOW_LABEL).unwrap();
|
||||
let settings_window = app_handle
|
||||
.get_webview_window(SETTINGS_WINDOW_LABEL)
|
||||
.unwrap();
|
||||
let check_window = app_handle.get_webview_window(CHECK_WINDOW_LABEL).unwrap();
|
||||
setup::default(
|
||||
app,
|
||||
app_handle,
|
||||
main_window.clone(),
|
||||
settings_window.clone(),
|
||||
check_window.clone(),
|
||||
);
|
||||
/* ----------- This code must be executed on the main thread and must not be relocated. ----------- */
|
||||
|
||||
Ok(())
|
||||
})
|
||||
@@ -291,7 +237,7 @@ pub fn run() {
|
||||
});
|
||||
}
|
||||
|
||||
pub async fn init<R: Runtime>(app_handle: &AppHandle<R>) {
|
||||
pub async fn init(app_handle: &AppHandle) {
|
||||
// Await the async functions to load the servers and tokens
|
||||
if let Err(err) = load_or_insert_default_server(app_handle).await {
|
||||
log::error!("Failed to load servers: {}", err);
|
||||
@@ -315,12 +261,22 @@ pub async fn init<R: Runtime>(app_handle: &AppHandle<R>) {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn show_coco<R: Runtime>(app_handle: AppHandle<R>) {
|
||||
async fn show_coco(app_handle: AppHandle) {
|
||||
if let Some(window) = app_handle.get_webview_window(MAIN_WINDOW_LABEL) {
|
||||
move_window_to_active_monitor(&window);
|
||||
|
||||
let _ = window.show();
|
||||
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 _ = app_handle.emit("show-coco", ());
|
||||
@@ -328,7 +284,7 @@ async fn show_coco<R: Runtime>(app_handle: AppHandle<R>) {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn hide_coco<R: Runtime>(app: AppHandle<R>) {
|
||||
async fn hide_coco(app: AppHandle) {
|
||||
if let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) {
|
||||
if let Err(err) = window.hide() {
|
||||
log::error!("Failed to hide the window: {}", err);
|
||||
@@ -340,7 +296,7 @@ async fn hide_coco<R: Runtime>(app: AppHandle<R>) {
|
||||
}
|
||||
}
|
||||
|
||||
fn move_window_to_active_monitor<R: Runtime>(window: &WebviewWindow<R>) {
|
||||
fn move_window_to_active_monitor(window: &WebviewWindow) {
|
||||
//dbg!("Moving window to active monitor");
|
||||
// Try to get the available monitors, handle failure gracefully
|
||||
let available_monitors = match window.available_monitors() {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use crate::common::error::SearchError;
|
||||
use crate::common::register::SearchSourceRegistry;
|
||||
use crate::common::search::{
|
||||
FailedRequest, MultiSourceQueryResponse, QueryHits, QueryResponse, QuerySource, SearchQuery,
|
||||
FailedRequest, MultiSourceQueryResponse, QueryHits, QuerySource, SearchQuery,
|
||||
};
|
||||
use crate::common::traits::SearchSource;
|
||||
use crate::server::servers::logout_coco_server;
|
||||
@@ -13,74 +13,24 @@ use reqwest::StatusCode;
|
||||
use std::cmp::Reverse;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::future::Future;
|
||||
use std::sync::Arc;
|
||||
use tauri::{AppHandle, Manager};
|
||||
use tokio::time::error::Elapsed;
|
||||
use tokio::time::{Duration, timeout};
|
||||
|
||||
/// Helper function to return the Future used for querying querysources.
|
||||
///
|
||||
/// It is a workaround for the limitations:
|
||||
///
|
||||
/// 1. 2 async blocks have different types in Rust's type system even though
|
||||
/// they are literally same
|
||||
/// 2. `futures::stream::FuturesUnordered` needs the `Futures` pushed to it to
|
||||
/// have only 1 type
|
||||
///
|
||||
/// Putting the async block in a function to unify the types.
|
||||
fn same_type_futures(
|
||||
query_source: QuerySource,
|
||||
query_source_trait_object: Arc<dyn SearchSource>,
|
||||
timeout_duration: Duration,
|
||||
search_query: SearchQuery,
|
||||
tauri_app_handle: AppHandle,
|
||||
) -> impl Future<
|
||||
Output = (
|
||||
QuerySource,
|
||||
Result<Result<QueryResponse, SearchError>, Elapsed>,
|
||||
),
|
||||
> + 'static {
|
||||
async move {
|
||||
(
|
||||
// Store `query_source` as part of future for debugging purposes.
|
||||
query_source,
|
||||
timeout(timeout_duration, async {
|
||||
query_source_trait_object
|
||||
.search(tauri_app_handle.clone(), search_query)
|
||||
.await
|
||||
})
|
||||
.await,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[named]
|
||||
#[tauri::command]
|
||||
pub async fn query_coco_fusion(
|
||||
app_handle: AppHandle,
|
||||
tauri_app_handle: AppHandle,
|
||||
from: u64,
|
||||
size: u64,
|
||||
query_strings: HashMap<String, String>,
|
||||
query_timeout: u64,
|
||||
) -> Result<MultiSourceQueryResponse, SearchError> {
|
||||
let query_keyword = query_strings
|
||||
.get("query")
|
||||
.unwrap_or(&"".to_string())
|
||||
.clone();
|
||||
|
||||
let opt_query_source_id = query_strings.get("querysource");
|
||||
|
||||
let search_sources = app_handle.state::<SearchSourceRegistry>();
|
||||
|
||||
let sources_future = search_sources.get_sources();
|
||||
let mut futures = FuturesUnordered::new();
|
||||
|
||||
let mut sources_list = sources_future.await;
|
||||
let sources_list_len = sources_list.len();
|
||||
|
||||
// Time limit for each query
|
||||
let search_sources = tauri_app_handle.state::<SearchSourceRegistry>();
|
||||
let query_source_list = search_sources.get_sources().await;
|
||||
let timeout_duration = Duration::from_millis(query_timeout);
|
||||
let search_query = SearchQuery::new(from, size, query_strings.clone());
|
||||
|
||||
log::debug!(
|
||||
"{}() invoked with parameters: from: [{}], size: [{}], query_strings: [{:?}], timeout: [{:?}]",
|
||||
@@ -91,68 +41,170 @@ pub async fn query_coco_fusion(
|
||||
timeout_duration
|
||||
);
|
||||
|
||||
let search_query = SearchQuery::new(from, size, query_strings.clone());
|
||||
|
||||
// Dispatch to different `query_coco_fusion_xxx()` functions.
|
||||
if let Some(query_source_id) = opt_query_source_id {
|
||||
// If this query source ID is specified, we only query this query source.
|
||||
log::debug!(
|
||||
"parameter [querysource={}] specified, will only query this querysource",
|
||||
query_source_id
|
||||
);
|
||||
|
||||
let opt_query_source_trait_object_index = sources_list
|
||||
.iter()
|
||||
.position(|query_source| &query_source.get_type().id == query_source_id);
|
||||
|
||||
let Some(query_source_trait_object_index) = opt_query_source_trait_object_index else {
|
||||
// It is possible (an edge case) that the frontend invokes `query_coco_fusion()` with a
|
||||
// datasource that does not exist in the source list:
|
||||
//
|
||||
// 1. Search applications
|
||||
// 2. Navigate to the application sub page
|
||||
// 3. Disable the application extension in settings
|
||||
// 4. hide the search window
|
||||
// 5. Re-open the search window and search for something
|
||||
//
|
||||
// The application search source is not in the source list because the extension
|
||||
// has been disabled, but the last search is indeed invoked with parameter
|
||||
// `datasource=application`.
|
||||
return Ok(MultiSourceQueryResponse {
|
||||
failed: Vec::new(),
|
||||
hits: Vec::new(),
|
||||
total_hits: 0,
|
||||
});
|
||||
};
|
||||
|
||||
let query_source_trait_object = sources_list.remove(query_source_trait_object_index);
|
||||
let query_source = query_source_trait_object.get_type();
|
||||
|
||||
futures.push(same_type_futures(
|
||||
query_source,
|
||||
query_source_trait_object,
|
||||
query_coco_fusion_single_query_source(
|
||||
tauri_app_handle,
|
||||
query_source_list,
|
||||
query_source_id.clone(),
|
||||
timeout_duration,
|
||||
search_query,
|
||||
app_handle.clone(),
|
||||
));
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
log::debug!(
|
||||
"will query querysources {:?}",
|
||||
sources_list
|
||||
.iter()
|
||||
.map(|search_source| search_source.get_type().id.clone())
|
||||
.collect::<Vec<String>>()
|
||||
);
|
||||
query_coco_fusion_multi_query_sources(
|
||||
tauri_app_handle,
|
||||
query_source_list,
|
||||
timeout_duration,
|
||||
search_query,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
for query_source_trait_object in sources_list {
|
||||
let query_source = query_source_trait_object.get_type().clone();
|
||||
futures.push(same_type_futures(
|
||||
query_source,
|
||||
query_source_trait_object,
|
||||
timeout_duration,
|
||||
search_query.clone(),
|
||||
app_handle.clone(),
|
||||
));
|
||||
/// Query only 1 query source.
|
||||
///
|
||||
/// The logic here is much simpler than `query_coco_fusion_multi_query_sources()`
|
||||
/// as we don't need to re-rank due to fact that this does not involve multiple
|
||||
/// query sources.
|
||||
async fn query_coco_fusion_single_query_source(
|
||||
tauri_app_handle: AppHandle,
|
||||
mut query_source_list: Vec<Arc<dyn SearchSource>>,
|
||||
id_of_query_source_to_query: String,
|
||||
timeout_duration: Duration,
|
||||
search_query: SearchQuery,
|
||||
) -> Result<MultiSourceQueryResponse, SearchError> {
|
||||
// If this query source ID is specified, we only query this query source.
|
||||
log::debug!(
|
||||
"parameter [querysource={}] specified, will only query this query source",
|
||||
id_of_query_source_to_query
|
||||
);
|
||||
|
||||
let opt_query_source_trait_object_index = query_source_list
|
||||
.iter()
|
||||
.position(|query_source| query_source.get_type().id == id_of_query_source_to_query);
|
||||
|
||||
let Some(query_source_trait_object_index) = opt_query_source_trait_object_index else {
|
||||
// It is possible (an edge case) that the frontend invokes `query_coco_fusion()`
|
||||
// with a querysource that does not exist in the source list:
|
||||
//
|
||||
// 1. Search applications
|
||||
// 2. Navigate to the application sub page
|
||||
// 3. Disable the application extension in settings, which removes this
|
||||
// query source from the list
|
||||
// 4. hide the search window
|
||||
// 5. Re-open the search window, you will still be in the sub page, type to search
|
||||
// something
|
||||
//
|
||||
// The application query source is not in the source list because the extension
|
||||
// was disabled and thus removed from the query sources, but the last
|
||||
// search is indeed invoked with parameter `querysource=application`.
|
||||
return Ok(MultiSourceQueryResponse {
|
||||
failed: Vec::new(),
|
||||
hits: Vec::new(),
|
||||
total_hits: 0,
|
||||
});
|
||||
};
|
||||
|
||||
let query_source_trait_object = query_source_list.remove(query_source_trait_object_index);
|
||||
let query_source = query_source_trait_object.get_type();
|
||||
let search_fut = query_source_trait_object.search(tauri_app_handle.clone(), search_query);
|
||||
let timeout_result = timeout(timeout_duration, search_fut).await;
|
||||
|
||||
let mut failed_requests: Vec<FailedRequest> = Vec::new();
|
||||
let mut hits = Vec::new();
|
||||
let mut total_hits = 0;
|
||||
|
||||
match timeout_result {
|
||||
// Ignore the `_timeout` variable as it won't provide any useful debugging information.
|
||||
Err(_timeout) => {
|
||||
log::warn!(
|
||||
"searching query source [{}] timed out, skip this request",
|
||||
query_source.id
|
||||
);
|
||||
}
|
||||
Ok(query_result) => match query_result {
|
||||
Ok(response) => {
|
||||
total_hits = response.total_hits;
|
||||
|
||||
for (document, score) in response.hits {
|
||||
log::debug!(
|
||||
"document from query source [{}]: ID [{}], title [{:?}], score [{}]",
|
||||
response.source.id,
|
||||
document.id,
|
||||
document.title,
|
||||
score
|
||||
);
|
||||
|
||||
let query_hit = QueryHits {
|
||||
source: Some(response.source.clone()),
|
||||
score,
|
||||
document,
|
||||
};
|
||||
|
||||
hits.push(query_hit);
|
||||
}
|
||||
}
|
||||
Err(search_error) => {
|
||||
query_coco_fusion_handle_failed_request(
|
||||
tauri_app_handle.clone(),
|
||||
&mut failed_requests,
|
||||
query_source,
|
||||
search_error,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
Ok(MultiSourceQueryResponse {
|
||||
failed: failed_requests,
|
||||
hits,
|
||||
total_hits,
|
||||
})
|
||||
}
|
||||
|
||||
async fn query_coco_fusion_multi_query_sources(
|
||||
tauri_app_handle: AppHandle,
|
||||
query_source_trait_object_list: Vec<Arc<dyn SearchSource>>,
|
||||
timeout_duration: Duration,
|
||||
search_query: SearchQuery,
|
||||
) -> Result<MultiSourceQueryResponse, SearchError> {
|
||||
log::debug!(
|
||||
"will query query sources {:?}",
|
||||
query_source_trait_object_list
|
||||
.iter()
|
||||
.map(|search_source| search_source.get_type().id.clone())
|
||||
.collect::<Vec<String>>()
|
||||
);
|
||||
|
||||
let query_keyword = search_query
|
||||
.query_strings
|
||||
.get("query")
|
||||
.unwrap_or(&"".to_string())
|
||||
.clone();
|
||||
let size = search_query.size;
|
||||
|
||||
let mut futures = FuturesUnordered::new();
|
||||
|
||||
let query_source_list_len = query_source_trait_object_list.len();
|
||||
for query_source_trait_object in query_source_trait_object_list {
|
||||
let query_source = query_source_trait_object.get_type().clone();
|
||||
let tauri_app_handle_clone = tauri_app_handle.clone();
|
||||
let search_query_clone = search_query.clone();
|
||||
|
||||
futures.push(async move {
|
||||
(
|
||||
// Store `query_source` as part of future for debugging purposes.
|
||||
query_source,
|
||||
timeout(timeout_duration, async {
|
||||
query_source_trait_object
|
||||
.search(tauri_app_handle_clone, search_query_clone)
|
||||
.await
|
||||
})
|
||||
.await,
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
let mut total_hits = 0;
|
||||
@@ -161,7 +213,7 @@ pub async fn query_coco_fusion(
|
||||
let mut all_hits: Vec<(String, QueryHits, f64)> = Vec::new();
|
||||
let mut hits_per_source: HashMap<String, Vec<(QueryHits, f64)>> = HashMap::new();
|
||||
|
||||
if sources_list_len > 1 {
|
||||
if query_source_list_len > 1 {
|
||||
need_rerank = true; // If we have more than one source, we need to rerank the hits
|
||||
}
|
||||
|
||||
@@ -173,25 +225,25 @@ pub async fn query_coco_fusion(
|
||||
"searching query source [{}] timed out, skip this request",
|
||||
query_source.id
|
||||
);
|
||||
// failed_requests.push(FailedRequest {
|
||||
// source: query_source,
|
||||
// status: 0,
|
||||
// error: Some("querying timed out".into()),
|
||||
// reason: None,
|
||||
// });
|
||||
}
|
||||
Ok(query_result) => match query_result {
|
||||
Ok(response) => {
|
||||
total_hits += response.total_hits;
|
||||
let source_id = response.source.id.clone();
|
||||
|
||||
for (doc, score) in response.hits {
|
||||
log::debug!("doc: {}, {:?}, {}", doc.id, doc.title, score);
|
||||
for (document, score) in response.hits {
|
||||
log::debug!(
|
||||
"document from query source [{}]: ID [{}], title [{:?}], score [{}]",
|
||||
response.source.id,
|
||||
document.id,
|
||||
document.title,
|
||||
score
|
||||
);
|
||||
|
||||
let query_hit = QueryHits {
|
||||
source: Some(response.source.clone()),
|
||||
score,
|
||||
document: doc,
|
||||
document,
|
||||
};
|
||||
|
||||
all_hits.push((source_id.clone(), query_hit.clone(), score));
|
||||
@@ -203,46 +255,13 @@ pub async fn query_coco_fusion(
|
||||
}
|
||||
}
|
||||
Err(search_error) => {
|
||||
log::error!(
|
||||
"searching query source [{}] failed, error [{}]",
|
||||
query_source.id,
|
||||
search_error
|
||||
);
|
||||
|
||||
let mut status_code_num: u16 = 0;
|
||||
|
||||
if let SearchError::HttpError {
|
||||
status_code: opt_status_code,
|
||||
msg: _,
|
||||
} = search_error
|
||||
{
|
||||
if let Some(status_code) = opt_status_code {
|
||||
status_code_num = status_code.as_u16();
|
||||
if status_code != StatusCode::OK {
|
||||
if status_code == StatusCode::UNAUTHORIZED {
|
||||
// This Coco server is unavailable. In addition to marking it as
|
||||
// unavailable, we need to log out because the status code is 401.
|
||||
logout_coco_server(app_handle.clone(), query_source.id.clone()).await.unwrap_or_else(|e| {
|
||||
panic!(
|
||||
"the search request to Coco server [id {}, name {}] failed with status code {}, the login token is invalid, we are trying to log out, but failed with error [{}]",
|
||||
query_source.id, query_source.name, StatusCode::UNAUTHORIZED, e
|
||||
);
|
||||
})
|
||||
} else {
|
||||
// This Coco server is unavailable
|
||||
mark_server_as_offline(app_handle.clone(), &query_source.id)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
failed_requests.push(FailedRequest {
|
||||
source: query_source,
|
||||
status: status_code_num,
|
||||
error: Some(search_error.to_string()),
|
||||
reason: None,
|
||||
});
|
||||
query_coco_fusion_handle_failed_request(
|
||||
tauri_app_handle.clone(),
|
||||
&mut failed_requests,
|
||||
query_source,
|
||||
search_error,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -402,3 +421,54 @@ fn boosted_levenshtein_rerank(query: &str, titles: Vec<(usize, &str)>) -> Vec<(u
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Helper function to handle a failed request.
|
||||
///
|
||||
/// Extracted as a function because `query_coco_fusion_single_query_source()` and
|
||||
/// `query_coco_fusion_multi_query_sources()` share the same error handling logic.
|
||||
async fn query_coco_fusion_handle_failed_request(
|
||||
tauri_app_handle: AppHandle,
|
||||
failed_requests: &mut Vec<FailedRequest>,
|
||||
query_source: QuerySource,
|
||||
search_error: SearchError,
|
||||
) {
|
||||
log::error!(
|
||||
"searching query source [{}] failed, error [{}]",
|
||||
query_source.id,
|
||||
search_error
|
||||
);
|
||||
|
||||
let mut status_code_num: u16 = 0;
|
||||
|
||||
if let SearchError::HttpError {
|
||||
status_code: opt_status_code,
|
||||
msg: _,
|
||||
} = search_error
|
||||
{
|
||||
if let Some(status_code) = opt_status_code {
|
||||
status_code_num = status_code.as_u16();
|
||||
if status_code != StatusCode::OK {
|
||||
if status_code == StatusCode::UNAUTHORIZED {
|
||||
// This Coco server is unavailable. In addition to marking it as
|
||||
// unavailable, we need to log out because the status code is 401.
|
||||
logout_coco_server(tauri_app_handle.clone(), query_source.id.to_string()).await.unwrap_or_else(|e| {
|
||||
panic!(
|
||||
"the search request to Coco server [id {}, name {}] failed with status code {}, the login token is invalid, we are trying to log out, but failed with error [{}]",
|
||||
query_source.id, query_source.name, StatusCode::UNAUTHORIZED, e
|
||||
);
|
||||
})
|
||||
} else {
|
||||
// This Coco server is unavailable
|
||||
mark_server_as_offline(tauri_app_handle.clone(), &query_source.id).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
failed_requests.push(FailedRequest {
|
||||
source: query_source,
|
||||
status: status_code_num,
|
||||
error: Some(search_error.to_string()),
|
||||
reason: None,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -72,11 +72,19 @@ pub async fn upload_attachment(
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn get_attachment(server_id: String, session_id: String) -> Result<Value, String> {
|
||||
let mut query_params = Vec::new();
|
||||
query_params.push(format!("session={}", session_id));
|
||||
pub async fn get_attachment_by_ids(
|
||||
server_id: String,
|
||||
attachments: Vec<String>,
|
||||
) -> Result<Value, String> {
|
||||
println!("get_attachment_by_ids server_id: {}", server_id);
|
||||
println!("get_attachment_by_ids attachments: {:?}", attachments);
|
||||
|
||||
let response = HttpClient::get(&server_id, "/attachment/_search", Some(query_params))
|
||||
let request_body = serde_json::json!({
|
||||
"attachments": attachments
|
||||
});
|
||||
let body = reqwest::Body::from(serde_json::to_string(&request_body).unwrap());
|
||||
|
||||
let response = HttpClient::post(&server_id, "/attachment/_search", None, Some(body))
|
||||
.await
|
||||
.map_err(|e| format!("Request error: {}", e))?;
|
||||
|
||||
|
||||
@@ -4,17 +4,17 @@ use crate::server::servers::{
|
||||
get_server_by_id, persist_servers, persist_servers_token, save_access_token, save_server,
|
||||
try_register_server_to_search_source,
|
||||
};
|
||||
use tauri::{AppHandle, Runtime};
|
||||
use tauri::AppHandle;
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn request_access_token_url(request_id: &str) -> String {
|
||||
// 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]
|
||||
pub async fn handle_sso_callback<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
pub async fn handle_sso_callback(
|
||||
app_handle: AppHandle,
|
||||
server_id: String,
|
||||
request_id: String,
|
||||
code: String,
|
||||
|
||||
@@ -6,7 +6,7 @@ use http::StatusCode;
|
||||
use lazy_static::lazy_static;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, RwLock};
|
||||
use tauri::{AppHandle, Runtime};
|
||||
use tauri::AppHandle;
|
||||
|
||||
lazy_static! {
|
||||
static ref CONNECTOR_CACHE: Arc<RwLock<HashMap<String, HashMap<String, Connector>>>> =
|
||||
@@ -29,7 +29,7 @@ pub fn get_connector_by_id(server_id: &str, connector_id: &str) -> Option<Connec
|
||||
Some(connector.clone())
|
||||
}
|
||||
|
||||
pub async fn refresh_all_connectors<R: Runtime>(app_handle: &AppHandle<R>) -> Result<(), String> {
|
||||
pub async fn refresh_all_connectors(app_handle: &AppHandle) -> Result<(), String> {
|
||||
let servers = get_all_servers().await;
|
||||
|
||||
// Collect all the tasks for fetching and refreshing connectors
|
||||
@@ -122,8 +122,8 @@ pub async fn fetch_connectors_by_server(id: &str) -> Result<Vec<Connector>, Stri
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_connectors_by_server<R: Runtime>(
|
||||
_app_handle: AppHandle<R>,
|
||||
pub async fn get_connectors_by_server(
|
||||
_app_handle: AppHandle,
|
||||
id: String,
|
||||
) -> Result<Vec<Connector>, String> {
|
||||
let connectors = fetch_connectors_by_server(&id).await?;
|
||||
|
||||
@@ -7,7 +7,7 @@ use http::StatusCode;
|
||||
use lazy_static::lazy_static;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, RwLock};
|
||||
use tauri::{AppHandle, Runtime};
|
||||
use tauri::AppHandle;
|
||||
|
||||
lazy_static! {
|
||||
static ref DATASOURCE_CACHE: Arc<RwLock<HashMap<String, HashMap<String, DataSource>>>> =
|
||||
@@ -31,7 +31,7 @@ pub fn get_datasources_from_cache(server_id: &str) -> Option<HashMap<String, Dat
|
||||
Some(server_cache.clone())
|
||||
}
|
||||
|
||||
pub async fn refresh_all_datasources<R: Runtime>(_app_handle: &AppHandle<R>) -> Result<(), String> {
|
||||
pub async fn refresh_all_datasources(_app_handle: &AppHandle) -> Result<(), String> {
|
||||
// dbg!("Attempting to refresh all datasources");
|
||||
|
||||
let servers = get_all_servers().await;
|
||||
|
||||
@@ -11,4 +11,3 @@ pub mod servers;
|
||||
pub mod synthesize;
|
||||
pub mod system_settings;
|
||||
pub mod transcription;
|
||||
pub mod websocket;
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
use crate::common::http::get_response_body_text;
|
||||
use crate::common::profile::UserProfile;
|
||||
use crate::server::http_client::HttpClient;
|
||||
use tauri::{AppHandle, Runtime};
|
||||
use tauri::AppHandle;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_user_profiles<R: Runtime>(
|
||||
_app_handle: AppHandle<R>,
|
||||
pub async fn get_user_profiles(
|
||||
_app_handle: AppHandle,
|
||||
server_id: String,
|
||||
) -> Result<UserProfile, String> {
|
||||
// Use the generic GET method from HttpClient
|
||||
|
||||
@@ -13,7 +13,6 @@ use serde_json::Value as JsonValue;
|
||||
use serde_json::from_value;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::LazyLock;
|
||||
use tauri::Runtime;
|
||||
use tauri::{AppHandle, Manager};
|
||||
use tauri_plugin_store::StoreExt;
|
||||
use tokio::sync::RwLock;
|
||||
@@ -70,7 +69,7 @@ async fn remove_server_by_id(id: &str) -> Option<Server> {
|
||||
cache.remove(id)
|
||||
}
|
||||
|
||||
pub async fn persist_servers<R: Runtime>(app_handle: &AppHandle<R>) -> Result<(), String> {
|
||||
pub async fn persist_servers(app_handle: &AppHandle) -> Result<(), String> {
|
||||
let cache = SERVER_LIST_CACHE.read().await;
|
||||
|
||||
// Convert HashMap to Vec for serialization (iterating over values of HashMap)
|
||||
@@ -99,7 +98,7 @@ pub async fn remove_server_token(id: &str) -> bool {
|
||||
cache.remove(id).is_some()
|
||||
}
|
||||
|
||||
pub async fn persist_servers_token<R: Runtime>(app_handle: &AppHandle<R>) -> Result<(), String> {
|
||||
pub async fn persist_servers_token(app_handle: &AppHandle) -> Result<(), String> {
|
||||
let cache = SERVER_TOKEN_LIST_CACHE.read().await;
|
||||
|
||||
// Convert HashMap to Vec for serialization (iterating over values of HashMap)
|
||||
@@ -158,9 +157,7 @@ fn get_default_server() -> Server {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn load_servers_token<R: Runtime>(
|
||||
app_handle: &AppHandle<R>,
|
||||
) -> Result<Vec<ServerAccessToken>, String> {
|
||||
pub async fn load_servers_token(app_handle: &AppHandle) -> Result<Vec<ServerAccessToken>, String> {
|
||||
log::debug!("Attempting to load servers token");
|
||||
|
||||
let store = app_handle
|
||||
@@ -219,7 +216,7 @@ pub async fn load_servers_token<R: Runtime>(
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn load_servers<R: Runtime>(app_handle: &AppHandle<R>) -> Result<Vec<Server>, String> {
|
||||
pub async fn load_servers(app_handle: &AppHandle) -> Result<Vec<Server>, String> {
|
||||
let store = app_handle
|
||||
.store(COCO_TAURI_STORE)
|
||||
.expect("create or load a store should not fail");
|
||||
@@ -276,9 +273,7 @@ pub async fn load_servers<R: Runtime>(app_handle: &AppHandle<R>) -> Result<Vec<S
|
||||
}
|
||||
|
||||
/// Function to load servers or insert a default one if none exist
|
||||
pub async fn load_or_insert_default_server<R: Runtime>(
|
||||
app_handle: &AppHandle<R>,
|
||||
) -> Result<Vec<Server>, String> {
|
||||
pub async fn load_or_insert_default_server(app_handle: &AppHandle) -> Result<Vec<Server>, String> {
|
||||
log::debug!("Attempting to load or insert default server");
|
||||
|
||||
let exists_servers = load_servers(&app_handle).await;
|
||||
@@ -296,9 +291,7 @@ pub async fn load_or_insert_default_server<R: Runtime>(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_coco_servers<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
) -> Result<Vec<Server>, String> {
|
||||
pub async fn list_coco_servers(app_handle: AppHandle) -> Result<Vec<Server>, String> {
|
||||
//hard fresh all server's info, in order to get the actual health
|
||||
refresh_all_coco_server_info(app_handle.clone()).await;
|
||||
|
||||
@@ -312,7 +305,7 @@ pub async fn get_all_servers() -> Vec<Server> {
|
||||
cache.values().cloned().collect()
|
||||
}
|
||||
|
||||
pub async fn refresh_all_coco_server_info<R: Runtime>(app_handle: AppHandle<R>) {
|
||||
pub async fn refresh_all_coco_server_info(app_handle: AppHandle) {
|
||||
let servers = get_all_servers().await;
|
||||
for server in servers {
|
||||
let _ = refresh_coco_server_info(app_handle.clone(), server.id.clone()).await;
|
||||
@@ -320,10 +313,7 @@ pub async fn refresh_all_coco_server_info<R: Runtime>(app_handle: AppHandle<R>)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn refresh_coco_server_info<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
id: String,
|
||||
) -> Result<Server, String> {
|
||||
pub async fn refresh_coco_server_info(app_handle: AppHandle, id: String) -> Result<Server, String> {
|
||||
// Retrieve the server from the cache
|
||||
let cached_server = {
|
||||
let cache = SERVER_LIST_CACHE.read().await;
|
||||
@@ -393,10 +383,7 @@ pub async fn refresh_coco_server_info<R: Runtime>(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn add_coco_server<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
endpoint: String,
|
||||
) -> Result<Server, String> {
|
||||
pub async fn add_coco_server(app_handle: AppHandle, endpoint: String) -> Result<Server, String> {
|
||||
load_or_insert_default_server(&app_handle)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to load default servers: {}", e))?;
|
||||
@@ -472,10 +459,7 @@ pub async fn add_coco_server<R: Runtime>(
|
||||
|
||||
#[tauri::command]
|
||||
#[function_name::named]
|
||||
pub async fn remove_coco_server<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
id: String,
|
||||
) -> Result<(), ()> {
|
||||
pub async fn remove_coco_server(app_handle: AppHandle, id: String) -> Result<(), ()> {
|
||||
let registry = app_handle.state::<SearchSourceRegistry>();
|
||||
registry.remove_source(id.as_str()).await;
|
||||
|
||||
@@ -507,7 +491,7 @@ pub async fn remove_coco_server<R: Runtime>(
|
||||
|
||||
#[tauri::command]
|
||||
#[function_name::named]
|
||||
pub async fn enable_server<R: Runtime>(app_handle: AppHandle<R>, id: String) -> Result<(), ()> {
|
||||
pub async fn enable_server(app_handle: AppHandle, id: String) -> Result<(), ()> {
|
||||
let opt_server = get_server_by_id(id.as_str()).await;
|
||||
|
||||
let Some(mut server) = opt_server else {
|
||||
@@ -532,7 +516,7 @@ pub async fn enable_server<R: Runtime>(app_handle: AppHandle<R>, id: String) ->
|
||||
|
||||
#[tauri::command]
|
||||
#[function_name::named]
|
||||
pub async fn disable_server<R: Runtime>(app_handle: AppHandle<R>, id: String) -> Result<(), ()> {
|
||||
pub async fn disable_server(app_handle: AppHandle, id: String) -> Result<(), ()> {
|
||||
let opt_server = get_server_by_id(id.as_str()).await;
|
||||
|
||||
let Some(mut server) = opt_server else {
|
||||
@@ -560,10 +544,7 @@ pub async fn disable_server<R: Runtime>(app_handle: AppHandle<R>, id: String) ->
|
||||
/// enabled.
|
||||
///
|
||||
/// For public Coco server, an extra token is required.
|
||||
pub async fn try_register_server_to_search_source(
|
||||
app_handle: AppHandle<impl Runtime>,
|
||||
server: &Server,
|
||||
) {
|
||||
pub async fn try_register_server_to_search_source(app_handle: AppHandle, server: &Server) {
|
||||
if server.enabled {
|
||||
log::trace!(
|
||||
"Server [name: {}, id: {}] is public: {} and available: {}",
|
||||
@@ -590,7 +571,7 @@ pub async fn try_register_server_to_search_source(
|
||||
|
||||
#[function_name::named]
|
||||
#[allow(unused)]
|
||||
async fn mark_server_as_online<R: Runtime>(app_handle: AppHandle<R>, id: &str) {
|
||||
async fn mark_server_as_online(app_handle: AppHandle, id: &str) {
|
||||
let server = get_server_by_id(id).await;
|
||||
if let Some(mut server) = server {
|
||||
server.available = true;
|
||||
@@ -608,7 +589,7 @@ async fn mark_server_as_online<R: Runtime>(app_handle: AppHandle<R>, id: &str) {
|
||||
}
|
||||
|
||||
#[function_name::named]
|
||||
pub(crate) async fn mark_server_as_offline<R: Runtime>(app_handle: AppHandle<R>, id: &str) {
|
||||
pub(crate) async fn mark_server_as_offline(app_handle: AppHandle, id: &str) {
|
||||
let server = get_server_by_id(id).await;
|
||||
if let Some(mut server) = server {
|
||||
server.available = false;
|
||||
@@ -628,10 +609,7 @@ pub(crate) async fn mark_server_as_offline<R: Runtime>(app_handle: AppHandle<R>,
|
||||
|
||||
#[tauri::command]
|
||||
#[function_name::named]
|
||||
pub async fn logout_coco_server<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
id: String,
|
||||
) -> Result<(), String> {
|
||||
pub async fn logout_coco_server(app_handle: AppHandle, id: String) -> Result<(), String> {
|
||||
log::debug!("Attempting to log out server by id: {}", &id);
|
||||
|
||||
// Check if the server exists
|
||||
|
||||
@@ -2,11 +2,11 @@ use crate::server::http_client::HttpClient;
|
||||
use futures_util::StreamExt;
|
||||
use http::Method;
|
||||
use serde_json::json;
|
||||
use tauri::{AppHandle, Emitter, Runtime, command};
|
||||
use tauri::{AppHandle, Emitter, command};
|
||||
|
||||
#[command]
|
||||
pub async fn synthesize<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
pub async fn synthesize(
|
||||
app_handle: AppHandle,
|
||||
client_id: String,
|
||||
server_id: String,
|
||||
voice: String,
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
use crate::server::servers::{get_server_by_id, get_server_token};
|
||||
use futures::StreamExt;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tauri::{AppHandle, Emitter, Runtime};
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::sync::{Mutex, mpsc};
|
||||
use tokio_tungstenite::MaybeTlsStream;
|
||||
use tokio_tungstenite::WebSocketStream;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
use tokio_tungstenite::tungstenite::handshake::client::generate_key;
|
||||
use tokio_tungstenite::{Connector, connect_async_tls_with_config};
|
||||
#[derive(Default)]
|
||||
pub struct WebSocketManager {
|
||||
connections: Arc<Mutex<HashMap<String, Arc<WebSocketInstance>>>>,
|
||||
}
|
||||
|
||||
struct WebSocketInstance {
|
||||
ws_connection: Mutex<WebSocketStream<MaybeTlsStream<TcpStream>>>, // No need to lock the entire map
|
||||
cancel_tx: mpsc::Sender<()>,
|
||||
}
|
||||
|
||||
fn convert_to_websocket(endpoint: &str) -> Result<String, String> {
|
||||
let url = url::Url::parse(endpoint).map_err(|e| format!("Invalid URL: {}", e))?;
|
||||
let ws_protocol = if url.scheme() == "https" {
|
||||
"wss://"
|
||||
} else {
|
||||
"ws://"
|
||||
};
|
||||
let host = url.host_str().ok_or("No host found in URL")?;
|
||||
let port = url
|
||||
.port_or_known_default()
|
||||
.unwrap_or(if url.scheme() == "https" { 443 } else { 80 });
|
||||
|
||||
let ws_endpoint = if port == 80 || port == 443 {
|
||||
format!("{}{}{}", ws_protocol, host, "/ws")
|
||||
} else {
|
||||
format!("{}{}:{}/ws", ws_protocol, host, port)
|
||||
};
|
||||
Ok(ws_endpoint)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn connect_to_server<R: Runtime>(
|
||||
tauri_app_handle: AppHandle<R>,
|
||||
id: String,
|
||||
client_id: String,
|
||||
state: tauri::State<'_, WebSocketManager>,
|
||||
app_handle: AppHandle,
|
||||
) -> Result<(), String> {
|
||||
let connections_clone = state.connections.clone();
|
||||
|
||||
// Disconnect old connection first
|
||||
disconnect(client_id.clone(), state.clone()).await.ok();
|
||||
|
||||
let server = get_server_by_id(&id)
|
||||
.await
|
||||
.ok_or(format!("Server with ID {} not found", id))?;
|
||||
let endpoint = convert_to_websocket(&server.endpoint)?;
|
||||
let token = get_server_token(&id).await.map(|t| t.access_token.clone());
|
||||
|
||||
let mut request =
|
||||
tokio_tungstenite::tungstenite::client::IntoClientRequest::into_client_request(&endpoint)
|
||||
.map_err(|e| format!("Failed to create WebSocket request: {}", e))?;
|
||||
|
||||
request
|
||||
.headers_mut()
|
||||
.insert("Connection", "Upgrade".parse().unwrap());
|
||||
request
|
||||
.headers_mut()
|
||||
.insert("Upgrade", "websocket".parse().unwrap());
|
||||
request
|
||||
.headers_mut()
|
||||
.insert("Sec-WebSocket-Version", "13".parse().unwrap());
|
||||
request
|
||||
.headers_mut()
|
||||
.insert("Sec-WebSocket-Key", generate_key().parse().unwrap());
|
||||
|
||||
if let Some(token) = token {
|
||||
request
|
||||
.headers_mut()
|
||||
.insert("X-API-TOKEN", token.parse().unwrap());
|
||||
}
|
||||
|
||||
let allow_self_signature =
|
||||
crate::settings::get_allow_self_signature(tauri_app_handle.clone()).await;
|
||||
let tls_connector = tokio_native_tls::native_tls::TlsConnector::builder()
|
||||
.danger_accept_invalid_certs(allow_self_signature)
|
||||
.build()
|
||||
.map_err(|e| format!("TLS build error: {:?}", e))?;
|
||||
|
||||
let connector = Connector::NativeTls(tls_connector.into());
|
||||
|
||||
let (ws_stream, _) = connect_async_tls_with_config(
|
||||
request,
|
||||
None, // WebSocketConfig
|
||||
true, // disable_nagle
|
||||
Some(connector), // Connector
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("WebSocket TLS error: {:?}", e))?;
|
||||
|
||||
let (cancel_tx, mut cancel_rx) = mpsc::channel(1);
|
||||
|
||||
let instance = Arc::new(WebSocketInstance {
|
||||
ws_connection: Mutex::new(ws_stream),
|
||||
cancel_tx,
|
||||
});
|
||||
|
||||
// Insert connection into the map (lock is held briefly)
|
||||
{
|
||||
let mut connections = connections_clone.lock().await;
|
||||
connections.insert(client_id.clone(), instance.clone());
|
||||
}
|
||||
|
||||
// Spawn WebSocket handler in a separate task
|
||||
let app_handle_clone = app_handle.clone();
|
||||
let client_id_clone = client_id.clone();
|
||||
tokio::spawn(async move {
|
||||
let ws = &mut *instance.ws_connection.lock().await;
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
msg = ws.next() => {
|
||||
match msg {
|
||||
Some(Ok(Message::Text(text))) => {
|
||||
let _ = app_handle_clone.emit(&format!("ws-message-{}", client_id_clone), text);
|
||||
},
|
||||
Some(Err(_)) | None => {
|
||||
log::debug!("WebSocket connection closed or error");
|
||||
let _ = app_handle_clone.emit(&format!("ws-error-{}", client_id_clone), id.clone());
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
_ = cancel_rx.recv() => {
|
||||
log::debug!("WebSocket connection cancelled");
|
||||
let _ = app_handle_clone.emit(&format!("ws-cancel-{}", client_id_clone), id.clone());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove connection after it closes
|
||||
let mut connections = connections_clone.lock().await;
|
||||
connections.remove(&client_id_clone);
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn disconnect(
|
||||
client_id: String,
|
||||
state: tauri::State<'_, WebSocketManager>,
|
||||
) -> Result<(), String> {
|
||||
let instance = {
|
||||
let mut connections = state.connections.lock().await;
|
||||
connections.remove(&client_id)
|
||||
};
|
||||
|
||||
if let Some(instance) = instance {
|
||||
let _ = instance.cancel_tx.send(()).await;
|
||||
|
||||
// Close WebSocket (lock only the connection, not the whole map)
|
||||
let mut ws = instance.ws_connection.lock().await;
|
||||
let _ = ws.close(None).await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
use crate::COCO_TAURI_STORE;
|
||||
use serde_json::Value as Json;
|
||||
use tauri::{AppHandle, Runtime};
|
||||
use tauri::AppHandle;
|
||||
use tauri_plugin_store::StoreExt;
|
||||
|
||||
const SETTINGS_ALLOW_SELF_SIGNATURE: &str = "settings_allow_self_signature";
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_allow_self_signature<R: Runtime>(tauri_app_handle: AppHandle<R>, value: bool) {
|
||||
pub async fn set_allow_self_signature(tauri_app_handle: AppHandle, value: bool) {
|
||||
use crate::server::http_client;
|
||||
|
||||
let store = tauri_app_handle
|
||||
@@ -40,7 +40,7 @@ pub async fn set_allow_self_signature<R: Runtime>(tauri_app_handle: AppHandle<R>
|
||||
}
|
||||
|
||||
/// Synchronous version of `async get_allow_self_signature()`.
|
||||
pub fn _get_allow_self_signature<R: Runtime>(tauri_app_handle: AppHandle<R>) -> bool {
|
||||
pub fn _get_allow_self_signature(tauri_app_handle: AppHandle) -> bool {
|
||||
let store = tauri_app_handle
|
||||
.store(COCO_TAURI_STORE)
|
||||
.unwrap_or_else(|e| {
|
||||
@@ -67,6 +67,6 @@ pub fn _get_allow_self_signature<R: Runtime>(tauri_app_handle: AppHandle<R>) ->
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_allow_self_signature<R: Runtime>(tauri_app_handle: AppHandle<R>) -> bool {
|
||||
pub async fn get_allow_self_signature(tauri_app_handle: AppHandle) -> bool {
|
||||
_get_allow_self_signature(tauri_app_handle)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use tauri::{App, WebviewWindow};
|
||||
use tauri::{AppHandle, WebviewWindow};
|
||||
|
||||
pub fn platform(
|
||||
_app: &mut App,
|
||||
_tauri_app_handle: &AppHandle,
|
||||
_main_window: WebviewWindow,
|
||||
_settings_window: WebviewWindow,
|
||||
_check_window: WebviewWindow,
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
//credits to: https://github.com/ayangweb/ayangweb-EcoPaste/blob/169323dbe6365ffe4abb64d867439ed2ea84c6d1/src-tauri/src/core/setup/mac.rs
|
||||
use tauri::{App, Emitter, EventTarget, WebviewWindow};
|
||||
use tauri_nspanel::{WebviewWindowExt, cocoa::appkit::NSWindowCollectionBehavior, panel_delegate};
|
||||
//! credits to: https://github.com/ayangweb/ayangweb-EcoPaste/blob/169323dbe6365ffe4abb64d867439ed2ea84c6d1/src-tauri/src/core/setup/mac.rs
|
||||
|
||||
use crate::common::MAIN_WINDOW_LABEL;
|
||||
|
||||
#[allow(non_upper_case_globals)]
|
||||
const NSWindowStyleMaskNonActivatingPanel: i32 = 1 << 7;
|
||||
use objc2_app_kit::NSNonactivatingPanelMask;
|
||||
use tauri::{AppHandle, Emitter, EventTarget, WebviewWindow};
|
||||
use tauri_nspanel::{WebviewWindowExt, cocoa::appkit::NSWindowCollectionBehavior, panel_delegate};
|
||||
|
||||
const WINDOW_FOCUS_EVENT: &str = "tauri://focus";
|
||||
const WINDOW_BLUR_EVENT: &str = "tauri://blur";
|
||||
@@ -13,7 +11,7 @@ const WINDOW_MOVED_EVENT: &str = "tauri://move";
|
||||
const WINDOW_RESIZED_EVENT: &str = "tauri://resize";
|
||||
|
||||
pub fn platform(
|
||||
_app: &mut App,
|
||||
_tauri_app_handle: &AppHandle,
|
||||
main_window: WebviewWindow,
|
||||
_settings_window: WebviewWindow,
|
||||
_check_window: WebviewWindow,
|
||||
@@ -21,15 +19,21 @@ pub fn platform(
|
||||
// Convert ns_window to ns_panel
|
||||
let panel = main_window.to_panel().unwrap();
|
||||
|
||||
// Make the window above the dock
|
||||
panel.set_level(20);
|
||||
|
||||
// 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);
|
||||
|
||||
// Share the window across all desktop spaces and full screen
|
||||
// Open the window in the active workspace and full screen
|
||||
panel.set_collection_behaviour(
|
||||
NSWindowCollectionBehavior::NSWindowCollectionBehaviorCanJoinAllSpaces
|
||||
NSWindowCollectionBehavior::NSWindowCollectionBehaviorMoveToActiveSpace
|
||||
| NSWindowCollectionBehavior::NSWindowCollectionBehaviorStationary
|
||||
| NSWindowCollectionBehavior::NSWindowCollectionBehaviorFullScreenAuxiliary,
|
||||
);
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
use tauri::{App, WebviewWindow};
|
||||
use crate::GLOBAL_TAURI_APP_HANDLE;
|
||||
use crate::autostart;
|
||||
use crate::common::register::SearchSourceRegistry;
|
||||
use crate::util::app_lang::update_app_lang;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::atomic::Ordering;
|
||||
use tauri::{AppHandle, Manager, WebviewWindow};
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod mac;
|
||||
@@ -19,7 +25,7 @@ pub use windows::*;
|
||||
pub use linux::*;
|
||||
|
||||
pub fn default(
|
||||
app: &mut App,
|
||||
tauri_app_handle: &AppHandle,
|
||||
main_window: WebviewWindow,
|
||||
settings_window: WebviewWindow,
|
||||
check_window: WebviewWindow,
|
||||
@@ -29,9 +35,83 @@ pub fn default(
|
||||
main_window.open_devtools();
|
||||
|
||||
platform(
|
||||
app,
|
||||
tauri_app_handle,
|
||||
main_window.clone(),
|
||||
settings_window.clone(),
|
||||
check_window.clone(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Indicates if the setup job is completed.
|
||||
static BACKEND_SETUP_COMPLETED: AtomicBool = AtomicBool::new(false);
|
||||
/// 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
|
||||
/// frontend, or the App will panic due to races[1]. The way we coordinate is to
|
||||
/// expose this function as a Tauri command, and let the frontend code invoke
|
||||
/// it.
|
||||
///
|
||||
/// The frontend code should ensure that:
|
||||
///
|
||||
/// 1. This command gets called before invoking other commands.
|
||||
/// 2. This command should only be called once.
|
||||
///
|
||||
/// [1]: For instance, Tauri command `list_extensions()` relies on an in-memory
|
||||
/// extension list that won't be initialized until `init_extensions()` gets
|
||||
/// called. If the frontend code invokes `list_extensions()` before `init_extension()`
|
||||
/// gets executed, we get a panic.
|
||||
#[tauri::command]
|
||||
pub(crate) async fn backend_setup(tauri_app_handle: AppHandle, app_lang: String) {
|
||||
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;
|
||||
}
|
||||
|
||||
GLOBAL_TAURI_APP_HANDLE
|
||||
.set(tauri_app_handle.clone())
|
||||
.expect("global tauri AppHandle already initialized");
|
||||
log::trace!("global Tauri AppHandle set");
|
||||
|
||||
let registry = SearchSourceRegistry::default();
|
||||
tauri_app_handle.manage(registry); // Store registry in Tauri's app state
|
||||
|
||||
// This has to be called before initializing extensions as doing that
|
||||
// requires access to the shortcut store, which will be set by this
|
||||
// function.
|
||||
//
|
||||
//
|
||||
// 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;
|
||||
|
||||
if let Err(err) = crate::extension::init_extensions(&tauri_app_handle).await {
|
||||
log::error!(
|
||||
"failed to initialize extension-related stuff, error [{}]",
|
||||
err
|
||||
);
|
||||
}
|
||||
|
||||
autostart::ensure_autostart_state_consistent(&tauri_app_handle).unwrap();
|
||||
|
||||
update_app_lang(app_lang).await;
|
||||
|
||||
// Invoked, now update the state
|
||||
BACKEND_SETUP_COMPLETED.store(true, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use tauri::{App, WebviewWindow};
|
||||
use tauri::{AppHandle, WebviewWindow};
|
||||
|
||||
pub fn platform(
|
||||
_app: &mut App,
|
||||
_tauri_app_handle: &AppHandle,
|
||||
_main_window: WebviewWindow,
|
||||
_settings_window: WebviewWindow,
|
||||
_check_window: WebviewWindow,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::common::MAIN_WINDOW_LABEL;
|
||||
use crate::{COCO_TAURI_STORE, hide_coco, show_coco};
|
||||
use tauri::{App, AppHandle, Manager, Runtime, async_runtime};
|
||||
use tauri::{AppHandle, Manager, async_runtime};
|
||||
use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState};
|
||||
use tauri_plugin_store::{JsonValue, StoreExt};
|
||||
|
||||
@@ -16,9 +17,9 @@ const DEFAULT_SHORTCUT: &str = "command+shift+space";
|
||||
const DEFAULT_SHORTCUT: &str = "ctrl+shift+space";
|
||||
|
||||
/// Set up the shortcut upon app start.
|
||||
pub fn enable_shortcut(app: &App) {
|
||||
pub fn enable_shortcut(tauri_app_handle: &AppHandle) {
|
||||
log::trace!("setting up Coco hotkey");
|
||||
let store = app
|
||||
let store = tauri_app_handle
|
||||
.store(COCO_TAURI_STORE)
|
||||
.expect("creating a store should not fail");
|
||||
|
||||
@@ -33,7 +34,7 @@ pub fn enable_shortcut(app: &App) {
|
||||
let stored_shortcut = stored_shortcut_str
|
||||
.parse::<Shortcut>()
|
||||
.expect("stored shortcut string should be valid");
|
||||
_register_shortcut_upon_start(app, stored_shortcut);
|
||||
_register_shortcut_upon_start(tauri_app_handle, stored_shortcut);
|
||||
} else {
|
||||
store.set(
|
||||
COCO_GLOBAL_SHORTCUT,
|
||||
@@ -42,7 +43,7 @@ pub fn enable_shortcut(app: &App) {
|
||||
let default_shortcut = DEFAULT_SHORTCUT
|
||||
.parse::<Shortcut>()
|
||||
.expect("default shortcut should never be invalid");
|
||||
_register_shortcut_upon_start(app, default_shortcut);
|
||||
_register_shortcut_upon_start(tauri_app_handle, default_shortcut);
|
||||
}
|
||||
log::trace!("Coco hotkey has been set");
|
||||
}
|
||||
@@ -50,14 +51,14 @@ pub fn enable_shortcut(app: &App) {
|
||||
/// Get the stored shortcut as a string, same as [`_get_shortcut()`], except that
|
||||
/// this is a `tauri::command` interface.
|
||||
#[tauri::command]
|
||||
pub async fn get_current_shortcut<R: Runtime>(app: AppHandle<R>) -> Result<String, String> {
|
||||
pub async fn get_current_shortcut(app: AppHandle) -> Result<String, String> {
|
||||
let shortcut = _get_shortcut(&app);
|
||||
Ok(shortcut)
|
||||
}
|
||||
|
||||
/// Get the current shortcut and unregister it on the tauri side.
|
||||
#[tauri::command]
|
||||
pub async fn unregister_shortcut<R: Runtime>(app: AppHandle<R>) {
|
||||
pub async fn unregister_shortcut(app: AppHandle) {
|
||||
let shortcut_str = _get_shortcut(&app);
|
||||
let shortcut = shortcut_str
|
||||
.parse::<Shortcut>()
|
||||
@@ -70,9 +71,9 @@ pub async fn unregister_shortcut<R: Runtime>(app: AppHandle<R>) {
|
||||
|
||||
/// Change the global shortcut to `key`.
|
||||
#[tauri::command]
|
||||
pub async fn change_shortcut<R: Runtime>(
|
||||
app: AppHandle<R>,
|
||||
_window: tauri::Window<R>,
|
||||
pub async fn change_shortcut(
|
||||
app: AppHandle,
|
||||
_window: tauri::Window,
|
||||
key: String,
|
||||
) -> Result<(), String> {
|
||||
println!("key {}:", key);
|
||||
@@ -94,7 +95,7 @@ pub async fn change_shortcut<R: Runtime>(
|
||||
}
|
||||
|
||||
/// Helper function to register a shortcut, used for shortcut updates.
|
||||
fn _register_shortcut<R: Runtime>(app: &AppHandle<R>, shortcut: Shortcut) {
|
||||
fn _register_shortcut(app: &AppHandle, shortcut: Shortcut) {
|
||||
app.global_shortcut()
|
||||
.on_shortcut(shortcut, move |app, scut, event| {
|
||||
if scut == &shortcut {
|
||||
@@ -118,12 +119,9 @@ fn _register_shortcut<R: Runtime>(app: &AppHandle<R>, shortcut: Shortcut) {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
use crate::common::MAIN_WINDOW_LABEL;
|
||||
|
||||
/// Helper function to register a shortcut, used to set up the shortcut up App's first start.
|
||||
fn _register_shortcut_upon_start(app: &App, shortcut: Shortcut) {
|
||||
let handler = app.app_handle();
|
||||
handler
|
||||
fn _register_shortcut_upon_start(tauri_app_handle: &AppHandle, shortcut: Shortcut) {
|
||||
tauri_app_handle
|
||||
.plugin(
|
||||
tauri_plugin_global_shortcut::Builder::new()
|
||||
.with_handler(move |app, scut, event| {
|
||||
@@ -147,11 +145,14 @@ fn _register_shortcut_upon_start(app: &App, shortcut: Shortcut) {
|
||||
.build(),
|
||||
)
|
||||
.unwrap();
|
||||
app.global_shortcut().register(shortcut).unwrap();
|
||||
tauri_app_handle
|
||||
.global_shortcut()
|
||||
.register(shortcut)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// Helper function to get the stored global shortcut, as a string.
|
||||
pub fn _get_shortcut<R: Runtime>(app: &AppHandle<R>) -> String {
|
||||
pub fn _get_shortcut(app: &AppHandle) -> String {
|
||||
let store = app
|
||||
.get_store(COCO_TAURI_STORE)
|
||||
.expect("store should be loaded or created");
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
//! So we duplicate it here **in the MEMORY** and expose a setter method to the
|
||||
//! frontend so that the value can be updated and stay update-to-date.
|
||||
|
||||
use function_name::named;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
@@ -23,6 +22,10 @@ impl std::fmt::Display for Lang {
|
||||
}
|
||||
}
|
||||
|
||||
/// Frontend code uses "en" and "zh" to represent the Application language.
|
||||
///
|
||||
/// This impl is not meant to be used as a parser for locale strings such as
|
||||
/// "en_US" or "zh_CN".
|
||||
impl std::str::FromStr for Lang {
|
||||
type Err = String;
|
||||
|
||||
@@ -38,16 +41,13 @@ impl std::str::FromStr for Lang {
|
||||
/// Cache the language config in memory.
|
||||
static APP_LANG: RwLock<Option<Lang>> = RwLock::const_new(None);
|
||||
|
||||
/// Frontend code uses this interface to update the in-memory cached `APP_LANG` config.
|
||||
#[named]
|
||||
/// Update the in-memory cached `APP_LANG` config.
|
||||
#[tauri::command]
|
||||
pub(crate) async fn update_app_lang(lang: String) {
|
||||
let app_lang = lang.parse::<Lang>().unwrap_or_else(|e| {
|
||||
panic!(
|
||||
"frontend code passes an invalid argument [{}] to interface [{}], parsing error [{}]",
|
||||
lang,
|
||||
function_name!(),
|
||||
e
|
||||
"invalid argument [{}], could not parse it to [struct Lang], parsing error [{}]",
|
||||
lang, e
|
||||
)
|
||||
});
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ pub(crate) enum FileType {
|
||||
Unknown,
|
||||
}
|
||||
|
||||
async fn get_file_type(path: &str) -> FileType {
|
||||
fn get_file_type(path: &str) -> FileType {
|
||||
let path = camino::Utf8Path::new(path);
|
||||
|
||||
// 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]
|
||||
pub(crate) async fn get_file_icon(path: String) -> &'static str {
|
||||
let ty = get_file_type(path.as_str()).await;
|
||||
/// Synchronous version of `get_file_icon()`.
|
||||
pub(crate) fn sync_get_file_icon(path: &str) -> &'static str {
|
||||
let ty = get_file_type(path);
|
||||
type_to_icon(ty)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn get_file_icon(path: String) -> &'static str {
|
||||
sync_get_file_icon(&path)
|
||||
}
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
pub(crate) mod app_lang;
|
||||
pub(crate) mod file;
|
||||
pub(crate) mod path;
|
||||
pub(crate) mod platform;
|
||||
pub(crate) mod prevent_default;
|
||||
pub(crate) mod system_lang;
|
||||
pub(crate) mod updater;
|
||||
|
||||
use std::{path::Path, process::Command};
|
||||
use tauri::{AppHandle, Runtime};
|
||||
use tauri::AppHandle;
|
||||
use tauri_plugin_shell::ShellExt;
|
||||
|
||||
/// We use this env variable to determine the DE on Linux.
|
||||
const XDG_CURRENT_DESKTOP: &str = "XDG_CURRENT_DESKTOP";
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
enum LinuxDesktopEnvironment {
|
||||
pub(crate) enum LinuxDesktopEnvironment {
|
||||
Gnome,
|
||||
Kde,
|
||||
Unsupported { xdg_current_desktop: String },
|
||||
@@ -63,7 +66,7 @@ impl LinuxDesktopEnvironment {
|
||||
}
|
||||
|
||||
/// 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_str = de_os_str.into_string().unwrap_or_else(|_os_string| {
|
||||
panic!("${} should be UTF-8 encoded", XDG_CURRENT_DESKTOP);
|
||||
@@ -88,7 +91,7 @@ fn get_linux_desktop_environment() -> Option<LinuxDesktopEnvironment> {
|
||||
//
|
||||
// tauri_plugin_shell::open() is deprecated, but we still use it.
|
||||
#[allow(deprecated)]
|
||||
pub async fn open<R: Runtime>(app_handle: AppHandle<R>, path: String) -> Result<(), String> {
|
||||
pub async fn open(app_handle: AppHandle, path: String) -> Result<(), String> {
|
||||
if cfg!(target_os = "linux") {
|
||||
let borrowed_path = Path::new(&path);
|
||||
if let Some(file_extension) = borrowed_path.extension() {
|
||||
|
||||
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")
|
||||
}
|
||||
@@ -1,8 +1,22 @@
|
||||
use derive_more::Display;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::borrow::Cow;
|
||||
use strum::EnumCount;
|
||||
use strum::VariantArray;
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Copy, Clone, Hash, PartialEq, Eq, Display)]
|
||||
#[derive(
|
||||
Debug,
|
||||
Deserialize,
|
||||
Serialize,
|
||||
Copy,
|
||||
Clone,
|
||||
Hash,
|
||||
PartialEq,
|
||||
Eq,
|
||||
Display,
|
||||
EnumCount,
|
||||
VariantArray,
|
||||
)]
|
||||
#[serde(rename_all(serialize = "lowercase", deserialize = "lowercase"))]
|
||||
pub(crate) enum Platform {
|
||||
#[display("macOS")]
|
||||
@@ -18,7 +32,7 @@ impl Platform {
|
||||
pub(crate) fn current() -> Platform {
|
||||
let os_str = std::env::consts::OS;
|
||||
serde_plain::from_str(os_str).unwrap_or_else(|_e| {
|
||||
panic!("std::env::consts::OS is [{}], which is not a valid value for [enum Platform], valid values: ['macos', 'linux', 'windows']", os_str)
|
||||
panic!("std::env::consts::OS is [{}], which is not a valid value for [enum Platform], valid values: {:?}", os_str, Self::VARIANTS.iter().map(|platform|platform.to_string()).collect::<Vec<String>>());
|
||||
})
|
||||
}
|
||||
|
||||
@@ -31,4 +45,16 @@ impl Platform {
|
||||
Self::Linux => Cow::Owned(sysinfo::System::distribution_id()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the number of platforms supported by Coco.
|
||||
//
|
||||
// a.k.a., the number of this enum's variants.
|
||||
pub(crate) fn num_of_supported_platforms() -> usize {
|
||||
Platform::COUNT
|
||||
}
|
||||
|
||||
/// Returns a set that contains all the platforms.
|
||||
pub(crate) fn all() -> std::collections::HashSet<Self> {
|
||||
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()
|
||||
}
|
||||
14
src-tauri/src/util/system_lang.rs
Normal file
14
src-tauri/src/util/system_lang.rs
Normal file
@@ -0,0 +1,14 @@
|
||||
/// Helper function to get the system language.
|
||||
///
|
||||
/// We cannot return `enum Lang` here because Coco has limited language support
|
||||
/// but the OS supports many more languages.
|
||||
#[cfg(feature = "use_pizza_engine")]
|
||||
pub(crate) fn get_system_lang() -> String {
|
||||
use sys_locale::get_locale;
|
||||
|
||||
// fall back to English (general) when we cannot get the locale
|
||||
//
|
||||
// We replace '-' with '_' in applications-rs, to make the locales match,
|
||||
// we need to do this here as well.
|
||||
get_locale().unwrap_or("en".into()).replace('-', "_")
|
||||
}
|
||||
@@ -5,7 +5,7 @@ use tauri_plugin_updater::RemoteRelease;
|
||||
///
|
||||
/// If the version string is in the `x.y.z` format and does not include a build
|
||||
/// number, we assume a build number of 0.
|
||||
fn extract_version_number(version: &Version) -> u32 {
|
||||
fn extract_build_number(version: &Version) -> u32 {
|
||||
let pre = &version.pre;
|
||||
|
||||
if pre.is_empty() {
|
||||
@@ -52,8 +52,8 @@ fn extract_version_number(version: &Version) -> u32 {
|
||||
pub(crate) fn custom_version_comparator(local: Version, remote_release: RemoteRelease) -> bool {
|
||||
let remote = remote_release.version;
|
||||
|
||||
let local_build_number = extract_version_number(&local);
|
||||
let remote_build_number = extract_version_number(&remote);
|
||||
let local_build_number = extract_build_number(&local);
|
||||
let remote_build_number = extract_build_number(&remote);
|
||||
|
||||
let should_update = remote_build_number > local_build_number;
|
||||
log::debug!(
|
||||
@@ -65,3 +65,23 @@ pub(crate) fn custom_version_comparator(local: Version, remote_release: RemoteRe
|
||||
|
||||
should_update
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_extract_build_number() {
|
||||
// 0.6.0 => 0
|
||||
let version = Version::parse("0.6.0").unwrap();
|
||||
assert_eq!(extract_build_number(&version), 0);
|
||||
|
||||
// 0.6.0-2371 => 2371
|
||||
let version = Version::parse("0.6.0-2371").unwrap();
|
||||
assert_eq!(extract_build_number(&version), 2371);
|
||||
|
||||
// 0.6.0-SNAPSHOT-2371 => 2371
|
||||
let version = Version::parse("0.6.0-SNAPSHOT-2371").unwrap();
|
||||
assert_eq!(extract_build_number(&version), 2371);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,11 +126,9 @@
|
||||
"https://release.infinilabs.com/coco/app/.latest.json?target={{target}}&arch={{arch}}¤t_version={{current_version}}"
|
||||
]
|
||||
},
|
||||
"websocket": {},
|
||||
"shell": {},
|
||||
"globalShortcut": {},
|
||||
"deep-link": {
|
||||
"schema": "coco",
|
||||
"mobile": [
|
||||
{
|
||||
"host": "app.infini.cloud",
|
||||
|
||||
@@ -86,6 +86,7 @@ export const Get = <T>(
|
||||
} else {
|
||||
res = result?.data as FcResponse<T>;
|
||||
}
|
||||
|
||||
resolve([null, res as FcResponse<T>]);
|
||||
})
|
||||
.catch((err) => {
|
||||
@@ -103,7 +104,7 @@ export const Post = <T>(
|
||||
return new Promise((resolve) => {
|
||||
const appStore = JSON.parse(localStorage.getItem("app-store") || "{}");
|
||||
|
||||
let baseURL = appStore.state?.endpoint_http
|
||||
let baseURL = appStore.state?.endpoint_http;
|
||||
if (!baseURL || baseURL === "undefined") {
|
||||
baseURL = "";
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,5 +1,4 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { emit } from "@tauri-apps/api/event";
|
||||
|
||||
import {
|
||||
Server,
|
||||
@@ -8,7 +7,7 @@ import {
|
||||
GetResponse,
|
||||
UploadAttachmentPayload,
|
||||
UploadAttachmentResponse,
|
||||
GetAttachmentPayload,
|
||||
GetAttachmentByIdsPayload,
|
||||
GetAttachmentResponse,
|
||||
DeleteAttachmentPayload,
|
||||
TranscriptionPayload,
|
||||
@@ -16,25 +15,10 @@ import {
|
||||
MultiSourceQueryResponse,
|
||||
} from "@/types/commands";
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
import { useAuthStore } from "@/stores/authStore";
|
||||
import { useConnectStore } from "@/stores/connectStore";
|
||||
|
||||
export function handleLogout(serverId?: string) {
|
||||
const setIsCurrentLogin = useAuthStore.getState().setIsCurrentLogin;
|
||||
const { currentService, setCurrentService, serverList, setServerList } =
|
||||
useConnectStore.getState();
|
||||
const id = serverId || currentService?.id;
|
||||
if (!id) return;
|
||||
setIsCurrentLogin(false);
|
||||
emit("login_or_logout", false);
|
||||
if (currentService?.id === id) {
|
||||
setCurrentService({ ...currentService, profile: null });
|
||||
}
|
||||
const updatedServerList = serverList.map((server) =>
|
||||
server.id === id ? { ...server, profile: null } : server
|
||||
);
|
||||
setServerList(updatedServerList);
|
||||
}
|
||||
import {
|
||||
getCurrentWindowService,
|
||||
handleLogout,
|
||||
} from "@/commands/windowService";
|
||||
|
||||
// Endpoints that don't require authentication
|
||||
const WHITELIST_SERVERS = [
|
||||
@@ -54,15 +38,9 @@ async function invokeWithErrorHandler<T>(
|
||||
command: string,
|
||||
args?: Record<string, any>
|
||||
): Promise<T> {
|
||||
const isCurrentLogin = useAuthStore.getState().isCurrentLogin;
|
||||
const currentService = useConnectStore.getState().currentService;
|
||||
const service = await getCurrentWindowService();
|
||||
|
||||
// Not logged in
|
||||
console.log(command, isCurrentLogin, currentService?.profile);
|
||||
if (
|
||||
!WHITELIST_SERVERS.includes(command) &&
|
||||
(!isCurrentLogin || !currentService?.profile)
|
||||
) {
|
||||
if (!WHITELIST_SERVERS.includes(command) && !service?.profile) {
|
||||
console.error("This command requires authentication");
|
||||
throw new Error("This command requires authentication");
|
||||
}
|
||||
@@ -89,6 +67,18 @@ async function invokeWithErrorHandler<T>(
|
||||
}
|
||||
}
|
||||
|
||||
// Server Data log
|
||||
let parsedResult = result;
|
||||
let logData = result;
|
||||
if (typeof result === "string") {
|
||||
parsedResult = JSON.parse(result);
|
||||
logData = parsedResult;
|
||||
}
|
||||
infoLog({
|
||||
username: "@/commands/servers.ts",
|
||||
logName: command,
|
||||
})(logData);
|
||||
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
const errorMessage = error || "Command execution failed";
|
||||
@@ -172,14 +162,6 @@ export function mcp_server_search({
|
||||
return invokeWithErrorHandler(`mcp_server_search`, { id, queryParams });
|
||||
}
|
||||
|
||||
export function connect_to_server(id: string, clientId: string): Promise<void> {
|
||||
return invokeWithErrorHandler(`connect_to_server`, { id, clientId });
|
||||
}
|
||||
|
||||
export function disconnect(clientId: string): Promise<void> {
|
||||
return invokeWithErrorHandler(`disconnect`, { clientId });
|
||||
}
|
||||
|
||||
export function chat_history({
|
||||
serverId,
|
||||
from = 0,
|
||||
@@ -260,76 +242,40 @@ export function cancel_session_chat({
|
||||
});
|
||||
}
|
||||
|
||||
export function new_chat({
|
||||
serverId,
|
||||
websocketId,
|
||||
message,
|
||||
queryParams,
|
||||
}: {
|
||||
serverId: string;
|
||||
websocketId: string;
|
||||
message: string;
|
||||
queryParams?: Record<string, any>;
|
||||
}): Promise<GetResponse> {
|
||||
return invokeWithErrorHandler(`new_chat`, {
|
||||
serverId,
|
||||
websocketId,
|
||||
message,
|
||||
queryParams,
|
||||
});
|
||||
}
|
||||
|
||||
export function chat_create({
|
||||
serverId,
|
||||
message,
|
||||
attachments,
|
||||
queryParams,
|
||||
clientId,
|
||||
}: {
|
||||
serverId: string;
|
||||
message: string;
|
||||
attachments: string[];
|
||||
queryParams?: Record<string, any>;
|
||||
clientId: string;
|
||||
}): Promise<GetResponse> {
|
||||
return invokeWithErrorHandler(`chat_create`, {
|
||||
serverId,
|
||||
message,
|
||||
attachments,
|
||||
queryParams,
|
||||
clientId,
|
||||
});
|
||||
}
|
||||
|
||||
export function send_message({
|
||||
serverId,
|
||||
websocketId,
|
||||
sessionId,
|
||||
message,
|
||||
queryParams,
|
||||
}: {
|
||||
serverId: string;
|
||||
websocketId: string;
|
||||
sessionId: string;
|
||||
message: string;
|
||||
queryParams?: Record<string, any>;
|
||||
}): Promise<string> {
|
||||
return invokeWithErrorHandler(`send_message`, {
|
||||
serverId,
|
||||
websocketId,
|
||||
sessionId,
|
||||
message,
|
||||
queryParams,
|
||||
});
|
||||
}
|
||||
|
||||
export function chat_chat({
|
||||
serverId,
|
||||
sessionId,
|
||||
message,
|
||||
attachments,
|
||||
queryParams,
|
||||
clientId,
|
||||
}: {
|
||||
serverId: string;
|
||||
sessionId: string;
|
||||
message: string;
|
||||
attachments: string[];
|
||||
queryParams?: Record<string, any>;
|
||||
clientId: string;
|
||||
}): Promise<string> {
|
||||
@@ -337,6 +283,7 @@ export function chat_chat({
|
||||
serverId,
|
||||
sessionId,
|
||||
message,
|
||||
attachments,
|
||||
queryParams,
|
||||
clientId,
|
||||
});
|
||||
@@ -391,10 +338,13 @@ export const upload_attachment = async (payload: UploadAttachmentPayload) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const get_attachment = (payload: GetAttachmentPayload) => {
|
||||
return invokeWithErrorHandler<GetAttachmentResponse>("get_attachment", {
|
||||
...payload,
|
||||
});
|
||||
export const get_attachment_by_ids = (payload: GetAttachmentByIdsPayload) => {
|
||||
return invokeWithErrorHandler<GetAttachmentResponse>(
|
||||
"get_attachment_by_ids",
|
||||
{
|
||||
...payload,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export const delete_attachment = (payload: DeleteAttachmentPayload) => {
|
||||
@@ -420,4 +370,4 @@ export const query_coco_fusion = (payload: {
|
||||
|
||||
export const get_app_search_source = () => {
|
||||
return invokeWithErrorHandler<void>("get_app_search_source");
|
||||
};
|
||||
};
|
||||
|
||||
73
src/commands/windowService.ts
Normal file
73
src/commands/windowService.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { useConnectStore } from "@/stores/connectStore";
|
||||
import { SETTINGS_WINDOW_LABEL } from "@/constants";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
import { useAuthStore } from "@/stores/authStore";
|
||||
import { useExtensionsStore } from "@/stores/extensionsStore";
|
||||
|
||||
export async function getCurrentWindowService() {
|
||||
const currentService = useConnectStore.getState().currentService;
|
||||
const cloudSelectService = useConnectStore.getState().cloudSelectService;
|
||||
const windowLabel = await platformAdapter.getCurrentWindowLabel();
|
||||
|
||||
return windowLabel === SETTINGS_WINDOW_LABEL
|
||||
? cloudSelectService
|
||||
: currentService;
|
||||
}
|
||||
|
||||
export async function setCurrentWindowService(service: any, isAll?: boolean) {
|
||||
const { setCurrentService, setCloudSelectService } =
|
||||
useConnectStore.getState();
|
||||
// all refresh logout
|
||||
if (isAll) {
|
||||
setCloudSelectService(service);
|
||||
return setCurrentService(service);
|
||||
}
|
||||
// current refresh
|
||||
const windowLabel = await platformAdapter.getCurrentWindowLabel();
|
||||
|
||||
if (windowLabel === SETTINGS_WINDOW_LABEL) {
|
||||
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) {
|
||||
const setIsCurrentLogin = useAuthStore.getState().setIsCurrentLogin;
|
||||
const { serverList, setServerList } = useConnectStore.getState();
|
||||
|
||||
const service = await getCurrentWindowService();
|
||||
|
||||
const id = serverId || service?.id;
|
||||
if (!id) return;
|
||||
|
||||
// Update the status first
|
||||
setIsCurrentLogin(false);
|
||||
if (service?.id === id) {
|
||||
await setCurrentWindowService({ ...service, profile: null }, true);
|
||||
}
|
||||
const updatedServerList = serverList.map((server) =>
|
||||
server.id === id ? { ...server, profile: null } : server
|
||||
);
|
||||
setServerList(updatedServerList);
|
||||
}
|
||||
@@ -25,7 +25,7 @@ export const AssistantFetcher = ({
|
||||
query?: string;
|
||||
}) => {
|
||||
try {
|
||||
if (unrequitable()) {
|
||||
if (await unrequitable()) {
|
||||
return {
|
||||
total: 0,
|
||||
list: [],
|
||||
@@ -57,8 +57,6 @@ export const AssistantFetcher = ({
|
||||
|
||||
let assistantList = response?.hits?.hits ?? [];
|
||||
|
||||
console.log("assistantList", assistantList);
|
||||
|
||||
if (
|
||||
!currentAssistant?._id ||
|
||||
currentService?.id !== lastServerId.current
|
||||
|
||||
182
src/components/Assistant/AttachmentList.tsx
Normal file
182
src/components/Assistant/AttachmentList.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
import { FC, useEffect, useMemo } from "react";
|
||||
import { X } from "lucide-react";
|
||||
import { useAsyncEffect } from "ahooks";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useChatStore, UploadAttachments } from "@/stores/chatStore";
|
||||
import { useConnectStore } from "@/stores/connectStore";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
import Tooltip2 from "../Common/Tooltip2";
|
||||
import FileIcon from "../Common/Icons/FileIcon";
|
||||
import { filesize } from "@/utils";
|
||||
|
||||
const AttachmentList = () => {
|
||||
const { uploadAttachments, setUploadAttachments } = useChatStore();
|
||||
const { currentService } = useConnectStore();
|
||||
|
||||
const serverId = useMemo(() => {
|
||||
return currentService.id;
|
||||
}, [currentService]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
setUploadAttachments([]);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const uploadAttachment = async (data: UploadAttachments) => {
|
||||
const { uploading, uploaded, uploadFailed, path } = data;
|
||||
|
||||
if (uploading || uploaded || uploadFailed) return;
|
||||
|
||||
const { uploadAttachments } = useChatStore.getState();
|
||||
|
||||
const matched = uploadAttachments.find((item) => item.id === data.id);
|
||||
|
||||
if (matched) {
|
||||
matched.uploading = true;
|
||||
|
||||
setUploadAttachments(uploadAttachments);
|
||||
}
|
||||
|
||||
try {
|
||||
const attachmentIds: any = await platformAdapter.commands(
|
||||
"upload_attachment",
|
||||
{
|
||||
serverId,
|
||||
filePaths: [path],
|
||||
}
|
||||
);
|
||||
|
||||
if (!attachmentIds) {
|
||||
throw new Error("Failed to get attachment id");
|
||||
} else {
|
||||
Object.assign(data, {
|
||||
uploaded: true,
|
||||
attachmentId: attachmentIds[0],
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
Object.assign(data, {
|
||||
uploadFailed: true,
|
||||
failedMessage: String(error),
|
||||
});
|
||||
} finally {
|
||||
Object.assign(data, {
|
||||
uploading: false,
|
||||
});
|
||||
|
||||
setUploadAttachments(uploadAttachments);
|
||||
}
|
||||
};
|
||||
|
||||
useAsyncEffect(async () => {
|
||||
if (uploadAttachments.length === 0) return;
|
||||
|
||||
for (const item of uploadAttachments) {
|
||||
uploadAttachment(item);
|
||||
}
|
||||
}, [uploadAttachments]);
|
||||
|
||||
const deleteFile = async (id: string) => {
|
||||
const { uploadAttachments } = useChatStore.getState();
|
||||
|
||||
const matched = uploadAttachments.find((item) => item.id === id);
|
||||
|
||||
if (!matched) return;
|
||||
|
||||
const { uploadFailed, attachmentId } = matched;
|
||||
|
||||
setUploadAttachments(uploadAttachments.filter((file) => file.id !== id));
|
||||
|
||||
if (uploadFailed) return;
|
||||
|
||||
platformAdapter.commands("delete_attachment", {
|
||||
serverId,
|
||||
id: attachmentId,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-y-2 -mx-1 text-sm">
|
||||
{uploadAttachments.map((file) => {
|
||||
return (
|
||||
<AttachmentItem
|
||||
key={file.id}
|
||||
{...file}
|
||||
deletable
|
||||
onDelete={deleteFile}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface AttachmentItemProps extends UploadAttachments {
|
||||
deletable?: boolean;
|
||||
onDelete?: (id: string) => void;
|
||||
}
|
||||
|
||||
export const AttachmentItem: FC<AttachmentItemProps> = (props) => {
|
||||
const {
|
||||
id,
|
||||
name,
|
||||
path,
|
||||
extname,
|
||||
size,
|
||||
uploaded,
|
||||
attachmentId,
|
||||
uploadFailed,
|
||||
failedMessage,
|
||||
deletable,
|
||||
onDelete,
|
||||
} = props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div key={id} className="w-1/3 px-1">
|
||||
<div className="relative group flex items-center gap-1 p-1 rounded-[4px] bg-[#dedede] dark:bg-[#202126]">
|
||||
{(uploadFailed || attachmentId) && deletable && (
|
||||
<div
|
||||
className="absolute flex justify-center items-center size-[14px] bg-red-600 top-0 right-0 rounded-full cursor-pointer translate-x-[5px] -translate-y-[5px] transition opacity-0 group-hover:opacity-100 "
|
||||
onClick={() => {
|
||||
onDelete?.(id);
|
||||
}}
|
||||
>
|
||||
<X className="size-[10px] text-white" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FileIcon path={path} />
|
||||
|
||||
<div className="flex flex-col justify-between overflow-hidden">
|
||||
<div className="truncate text-sm text-[#333333] dark:text-[#D8D8D8]">
|
||||
{name}
|
||||
</div>
|
||||
|
||||
<div className="text-xs">
|
||||
{uploadFailed && failedMessage ? (
|
||||
<Tooltip2 content={failedMessage}>
|
||||
<span className="text-red-500">Upload Failed</span>
|
||||
</Tooltip2>
|
||||
) : (
|
||||
<div className="text-[#999]">
|
||||
{uploaded ? (
|
||||
<div className="flex gap-2">
|
||||
{extname && <span>{extname}</span>}
|
||||
<span>{filesize(size)}</span>
|
||||
</div>
|
||||
) : (
|
||||
<span>{t("assistant.fileList.uploading")}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AttachmentList;
|
||||
@@ -43,8 +43,13 @@ interface ChatAIProps {
|
||||
instanceId?: string;
|
||||
}
|
||||
|
||||
export interface SendMessageParams {
|
||||
message?: string;
|
||||
attachments?: string[];
|
||||
}
|
||||
|
||||
export interface ChatAIRef {
|
||||
init: (value: string) => void;
|
||||
init: (params: SendMessageParams) => void;
|
||||
cancelChat: () => void;
|
||||
clearChat: () => void;
|
||||
}
|
||||
@@ -188,7 +193,7 @@ const ChatAI = memo(
|
||||
isDeepThinkActive,
|
||||
isMCPActive,
|
||||
changeInput,
|
||||
showChatHistory,
|
||||
showChatHistory
|
||||
);
|
||||
|
||||
const { dealMsg } = useMessageHandler(
|
||||
@@ -225,10 +230,10 @@ const ChatAI = memo(
|
||||
}, [activeChat, chatClose]);
|
||||
|
||||
const init = useCallback(
|
||||
async (value: string) => {
|
||||
async (params: SendMessageParams) => {
|
||||
try {
|
||||
//console.log("init", curChatEnd, activeChat?._id);
|
||||
if (!isCurrentLogin) {
|
||||
if (isTauri && !isCurrentLogin) {
|
||||
addError("Please login to continue chatting");
|
||||
return;
|
||||
}
|
||||
@@ -237,9 +242,9 @@ const ChatAI = memo(
|
||||
return;
|
||||
}
|
||||
if (!activeChat?._id) {
|
||||
await createNewChat(value);
|
||||
await createNewChat(params);
|
||||
} else {
|
||||
await handleSendMessage(value, activeChat);
|
||||
await handleSendMessage(activeChat, params);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to initialize chat:", error);
|
||||
@@ -285,7 +290,10 @@ const ChatAI = memo(
|
||||
if (updatedChats.length > 0) {
|
||||
setActiveChat(updatedChats[0]);
|
||||
} else {
|
||||
init("");
|
||||
init({
|
||||
message: "",
|
||||
attachments: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -382,7 +390,7 @@ const ChatAI = memo(
|
||||
assistantIDs={assistantIDs}
|
||||
/>
|
||||
|
||||
{isCurrentLogin ? (
|
||||
{!isTauri || (isTauri && isCurrentLogin) ? (
|
||||
<>
|
||||
<ChatContent
|
||||
activeChat={activeChat}
|
||||
@@ -396,8 +404,8 @@ const ChatAI = memo(
|
||||
loadingStep={loadingStep}
|
||||
timedoutShow={timedoutShow}
|
||||
Question={Question}
|
||||
handleSendMessage={(value) =>
|
||||
handleSendMessage(value, activeChat)
|
||||
handleSendMessage={(message) =>
|
||||
handleSendMessage(activeChat, { message })
|
||||
}
|
||||
getFileUrl={getFileUrl}
|
||||
formatUrl={formatUrl}
|
||||
@@ -410,7 +418,11 @@ const ChatAI = memo(
|
||||
)}
|
||||
|
||||
{!activeChat?._id && !visibleStartPage && (
|
||||
<PrevSuggestion sendMessage={init} />
|
||||
<PrevSuggestion
|
||||
sendMessage={(message) => {
|
||||
init({ message });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -3,13 +3,14 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
import { ChatMessage } from "@/components/ChatMessage";
|
||||
import { Greetings } from "./Greetings";
|
||||
// import FileList from "@/components/Assistant/FileList";
|
||||
import AttachmentList from "@/components/Assistant/AttachmentList";
|
||||
import { useChatScroll } from "@/hooks/useChatScroll";
|
||||
import { useChatStore } from "@/stores/chatStore";
|
||||
|
||||
import type { Chat, IChunkData } from "@/types/chat";
|
||||
import { useConnectStore } from "@/stores/connectStore";
|
||||
// import SessionFile from "./SessionFile";
|
||||
import ScrollToBottom from "@/components/Common/ScrollToBottom";
|
||||
import { useChatStore } from "@/stores/chatStore";
|
||||
|
||||
interface ChatContentProps {
|
||||
activeChat?: Chat;
|
||||
@@ -44,14 +45,12 @@ export const ChatContent = ({
|
||||
handleSendMessage,
|
||||
formatUrl,
|
||||
}: ChatContentProps) => {
|
||||
// const sessionId = useConnectStore((state) => state.currentSessionId);
|
||||
const setCurrentSessionId = useConnectStore((state) => {
|
||||
return state.setCurrentSessionId;
|
||||
});
|
||||
const { currentSessionId, setCurrentSessionId } = useConnectStore();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
// const uploadFiles = useChatStore((state) => state.uploadFiles);
|
||||
const { uploadAttachments } = useChatStore();
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { scrollToBottom } = useChatScroll(messagesEndRef);
|
||||
@@ -168,13 +167,13 @@ export const ChatContent = ({
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* {uploadFiles.length > 0 && (
|
||||
<div key={sessionId} className="max-h-[120px] overflow-auto p-2">
|
||||
<FileList />
|
||||
{uploadAttachments.length > 0 && (
|
||||
<div key={currentSessionId} className="max-h-[120px] overflow-auto p-2">
|
||||
<AttachmentList />
|
||||
</div>
|
||||
)} */}
|
||||
)}
|
||||
|
||||
{/* {sessionId && <SessionFile sessionId={sessionId} />} */}
|
||||
{/* {currentSessionId && <SessionFile sessionId={currentSessionId} />} */}
|
||||
|
||||
<ScrollToBottom scrollRef={scrollRef} isAtBottom={isAtBottom} />
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,6 @@ import PinIcon from "@/icons/Pin";
|
||||
import WindowsFullIcon from "@/icons/WindowsFull";
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
import type { Chat } from "@/types/chat";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
import VisibleKey from "../Common/VisibleKey";
|
||||
import { useShortcutsStore } from "@/stores/shortcutsStore";
|
||||
import { HISTORY_PANEL_ID } from "@/constants";
|
||||
@@ -42,9 +41,9 @@ export function ChatHeader({
|
||||
|
||||
const togglePin = async () => {
|
||||
try {
|
||||
const newPinned = !isPinned;
|
||||
await platformAdapter.setAlwaysOnTop(newPinned);
|
||||
setIsPinned(newPinned);
|
||||
const { isPinned } = useAppStore.getState();
|
||||
|
||||
setIsPinned(!isPinned);
|
||||
} catch (err) {
|
||||
console.error("Failed to toggle window pin state:", err);
|
||||
setIsPinned(isPinned);
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { filesize } from "filesize";
|
||||
import { X } from "lucide-react";
|
||||
import { useAsyncEffect } from "ahooks";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useChatStore, UploadFile } from "@/stores/chatStore";
|
||||
import { useConnectStore } from "@/stores/connectStore";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
import Tooltip2 from "../Common/Tooltip2";
|
||||
import FileIcon from "../Common/Icons/FileIcon";
|
||||
|
||||
const FileList = () => {
|
||||
const { t } = useTranslation();
|
||||
const { uploadFiles, setUploadFiles } = useChatStore();
|
||||
const { currentService } = useConnectStore();
|
||||
|
||||
const serverId = useMemo(() => {
|
||||
return currentService.id;
|
||||
}, [currentService]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
setUploadFiles([]);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useAsyncEffect(async () => {
|
||||
if (uploadFiles.length === 0) return;
|
||||
|
||||
for await (const item of uploadFiles) {
|
||||
const { uploaded, path } = item;
|
||||
|
||||
if (uploaded) continue;
|
||||
|
||||
try {
|
||||
const attachmentIds: any = await platformAdapter.commands(
|
||||
"upload_attachment",
|
||||
{
|
||||
serverId,
|
||||
filePaths: [path],
|
||||
}
|
||||
);
|
||||
|
||||
if (!attachmentIds) {
|
||||
throw new Error("Failed to get attachment id");
|
||||
} else {
|
||||
Object.assign(item, {
|
||||
uploaded: true,
|
||||
attachmentId: attachmentIds[0],
|
||||
});
|
||||
}
|
||||
|
||||
setUploadFiles(uploadFiles);
|
||||
} catch (error) {
|
||||
Object.assign(item, {
|
||||
uploadFailed: true,
|
||||
failedMessage: String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [uploadFiles]);
|
||||
|
||||
const deleteFile = async (file: UploadFile) => {
|
||||
const { id, uploadFailed, attachmentId } = file;
|
||||
|
||||
setUploadFiles(uploadFiles.filter((file) => file.id !== id));
|
||||
|
||||
if (uploadFailed) return;
|
||||
|
||||
platformAdapter.commands("delete_attachment", {
|
||||
serverId,
|
||||
id: attachmentId,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-y-2 -mx-1 text-sm">
|
||||
{uploadFiles.map((file) => {
|
||||
const {
|
||||
id,
|
||||
name,
|
||||
path,
|
||||
extname,
|
||||
size,
|
||||
uploaded,
|
||||
attachmentId,
|
||||
uploadFailed,
|
||||
failedMessage,
|
||||
} = file;
|
||||
|
||||
return (
|
||||
<div key={id} className="w-1/3 px-1">
|
||||
<div className="relative group flex items-center gap-1 p-1 rounded-[4px] bg-[#dedede] dark:bg-[#202126]">
|
||||
{(uploadFailed || attachmentId) && (
|
||||
<div
|
||||
className="absolute flex justify-center items-center size-[14px] bg-red-600 top-0 right-0 rounded-full cursor-pointer translate-x-[5px] -translate-y-[5px] transition opacity-0 group-hover:opacity-100 "
|
||||
onClick={() => {
|
||||
deleteFile(file);
|
||||
}}
|
||||
>
|
||||
<X className="size-[10px] text-white" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FileIcon path={path} />
|
||||
|
||||
<div className="flex flex-col justify-between overflow-hidden">
|
||||
<div className="truncate text-[#333333] dark:text-[#D8D8D8]">
|
||||
{name}
|
||||
</div>
|
||||
|
||||
<div className="text-xs">
|
||||
{uploadFailed && failedMessage ? (
|
||||
<Tooltip2 content={failedMessage}>
|
||||
<span className="text-red-500">Upload Failed</span>
|
||||
</Tooltip2>
|
||||
) : (
|
||||
<div className="text-[#999]">
|
||||
{uploaded ? (
|
||||
<div className="flex gap-2">
|
||||
{extname && <span>{extname}</span>}
|
||||
<span>
|
||||
{filesize(size, { standard: "jedec", spacer: "" })}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<span>{t("assistant.fileList.uploading")}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileList;
|
||||
@@ -17,6 +17,8 @@ import { Server as IServer } from "@/types/server";
|
||||
import StatusIndicator from "@/components/Cloud/StatusIndicator";
|
||||
import { useAuthStore } from "@/stores/authStore";
|
||||
import { useSearchStore } from "@/stores/searchStore";
|
||||
import { useServers } from "@/hooks/useServers";
|
||||
import { getCurrentWindowService, setCurrentWindowService } from "@/commands/windowService";
|
||||
|
||||
interface ServerListProps {
|
||||
clearChat: () => void;
|
||||
@@ -25,17 +27,20 @@ interface ServerListProps {
|
||||
export function ServerList({ clearChat }: ServerListProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const isCurrentLogin = useAuthStore((state) => state.isCurrentLogin);
|
||||
const setIsCurrentLogin = useAuthStore((state) => state.setIsCurrentLogin);
|
||||
const serviceList = useShortcutsStore((state) => state.serviceList);
|
||||
const serviceListShortcut = useShortcutsStore(
|
||||
(state) => state.serviceListShortcut
|
||||
);
|
||||
const setEndpoint = useAppStore((state) => state.setEndpoint);
|
||||
const setCurrentService = useConnectStore((state) => state.setCurrentService);
|
||||
const isTauri = useAppStore((state) => state.isTauri);
|
||||
const currentService = useConnectStore((state) => state.currentService);
|
||||
const cloudSelectService = useConnectStore((state) => {
|
||||
return state.cloudSelectService;
|
||||
});
|
||||
|
||||
const { setMessages } = useChatStore();
|
||||
|
||||
const [serverList, setServerList] = useState<IServer[]>([]);
|
||||
const [list, setList] = useState<IServer[]>([]);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [highlightId, setHighlightId] = useState<string>("");
|
||||
|
||||
@@ -49,44 +54,49 @@ export function ServerList({ clearChat }: ServerListProps) {
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
const serverListButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const fetchServers = useCallback(
|
||||
async (resetSelection: boolean) => {
|
||||
platformAdapter.commands("list_coco_servers").then((res: any) => {
|
||||
console.log("list_coco_servers", res);
|
||||
if (!Array.isArray(res)) {
|
||||
// If res is not an array, it might be an error message or something else.
|
||||
// Log it and don't proceed.
|
||||
// console.log("list_coco_servers did not return an array:", res);
|
||||
setServerList([]); // Clear the list or handle as appropriate
|
||||
return;
|
||||
}
|
||||
const enabledServers = (res as IServer[])?.filter(
|
||||
(server) => server.enabled && server.available
|
||||
);
|
||||
const { refreshServerList } = useServers();
|
||||
const serverList = useConnectStore((state) => state.serverList);
|
||||
|
||||
setServerList(enabledServers);
|
||||
const switchServer = async (server: IServer) => {
|
||||
if (!server) return;
|
||||
try {
|
||||
// Switch UI first, then switch server connection
|
||||
await setCurrentWindowService(server);
|
||||
setEndpoint(server.endpoint);
|
||||
setMessages(""); // Clear previous messages
|
||||
clearChat();
|
||||
//
|
||||
if (!server.public && !server.profile) {
|
||||
setIsCurrentLogin(false);
|
||||
return;
|
||||
}
|
||||
//
|
||||
setIsCurrentLogin(true);
|
||||
} catch (error) {
|
||||
console.error("switchServer:", error);
|
||||
}
|
||||
};
|
||||
|
||||
if (resetSelection && enabledServers.length > 0) {
|
||||
const currentServiceExists = enabledServers.find(
|
||||
(server) => server.id === currentService?.id
|
||||
);
|
||||
const fetchServers = useCallback(async () => {
|
||||
const service = await getCurrentWindowService();
|
||||
|
||||
if (currentServiceExists) {
|
||||
switchServer(currentServiceExists);
|
||||
} else {
|
||||
switchServer(enabledServers[enabledServers.length - 1]);
|
||||
}
|
||||
}
|
||||
const enabledServers = serverList.filter(
|
||||
(server) => server.enabled && server.available
|
||||
);
|
||||
setList(enabledServers);
|
||||
|
||||
if (enabledServers.length > 0) {
|
||||
const serviceExists = enabledServers.find((server) => {
|
||||
return server.id === service?.id;
|
||||
});
|
||||
},
|
||||
[currentService?.id]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTauri) return;
|
||||
|
||||
fetchServers(true);
|
||||
}, [currentService?.enabled]);
|
||||
if (serviceExists) {
|
||||
switchServer(serviceExists);
|
||||
} else {
|
||||
switchServer(enabledServers[enabledServers.length - 1]);
|
||||
}
|
||||
}
|
||||
}, [currentService?.id, cloudSelectService?.id, serverList]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!askAiServerId || serverList.length === 0) return;
|
||||
@@ -104,25 +114,12 @@ export function ServerList({ clearChat }: ServerListProps) {
|
||||
useEffect(() => {
|
||||
if (!isTauri) return;
|
||||
|
||||
fetchServers(true);
|
||||
|
||||
const unlisten = platformAdapter.listenEvent("login_or_logout", (event) => {
|
||||
//console.log("Login or Logout:", currentService, event.payload);
|
||||
if (event.payload !== isCurrentLogin) {
|
||||
setIsCurrentLogin(!!event.payload);
|
||||
}
|
||||
fetchServers(true);
|
||||
});
|
||||
|
||||
return () => {
|
||||
// Cleanup logic if needed
|
||||
unlisten.then((fn) => fn());
|
||||
};
|
||||
}, []);
|
||||
fetchServers();
|
||||
}, [serverList]);
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setIsRefreshing(true);
|
||||
await fetchServers(false);
|
||||
await refreshServerList();
|
||||
setTimeout(() => setIsRefreshing(false), 1000);
|
||||
};
|
||||
|
||||
@@ -130,29 +127,10 @@ export function ServerList({ clearChat }: ServerListProps) {
|
||||
platformAdapter.emitEvent("open_settings", "connect");
|
||||
};
|
||||
|
||||
const switchServer = async (server: IServer) => {
|
||||
if (!server) return;
|
||||
try {
|
||||
// Switch UI first, then switch server connection
|
||||
setCurrentService(server);
|
||||
setEndpoint(server.endpoint);
|
||||
setMessages(""); // Clear previous messages
|
||||
clearChat();
|
||||
//
|
||||
if (!server.public && !server.profile) {
|
||||
setIsCurrentLogin(false);
|
||||
return;
|
||||
}
|
||||
//
|
||||
setIsCurrentLogin(true);
|
||||
} catch (error) {
|
||||
console.error("switchServer:", error);
|
||||
}
|
||||
};
|
||||
|
||||
useKeyPress(
|
||||
["uparrow", "downarrow", "enter"],
|
||||
(event, key) => {
|
||||
async (event, key) => {
|
||||
const service = await getCurrentWindowService();
|
||||
const isClose = isNil(serverListButtonRef.current?.dataset["open"]);
|
||||
const length = serverList.length;
|
||||
|
||||
@@ -162,9 +140,7 @@ export function ServerList({ clearChat }: ServerListProps) {
|
||||
event.preventDefault();
|
||||
|
||||
const currentIndex = serverList.findIndex((server) => {
|
||||
return (
|
||||
server.id === (highlightId === "" ? currentService?.id : highlightId)
|
||||
);
|
||||
return server.id === (highlightId === "" ? service?.id : highlightId);
|
||||
});
|
||||
|
||||
let nextIndex = currentIndex;
|
||||
@@ -197,7 +173,7 @@ export function ServerList({ clearChat }: ServerListProps) {
|
||||
<Popover ref={popoverRef} className="relative">
|
||||
<PopoverButton ref={serverListButtonRef} className="flex items-center">
|
||||
<VisibleKey
|
||||
shortcut={serviceList}
|
||||
shortcut={serviceListShortcut}
|
||||
onKeyPress={() => {
|
||||
serverListButtonRef.current?.click();
|
||||
}}
|
||||
@@ -240,8 +216,8 @@ export function ServerList({ clearChat }: ServerListProps) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{serverList.length > 0 ? (
|
||||
serverList.map((server) => (
|
||||
{list.length > 0 ? (
|
||||
list.map((server) => (
|
||||
<div
|
||||
key={server.id}
|
||||
onClick={() => switchServer(server)}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import clsx from "clsx";
|
||||
import { filesize } from "filesize";
|
||||
import { Files, Trash2, X } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -10,6 +9,7 @@ import { AttachmentHit } from "@/types/commands";
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
import FileIcon from "../Common/Icons/FileIcon";
|
||||
import { filesize } from "@/utils";
|
||||
|
||||
interface SessionFileProps {
|
||||
sessionId: string;
|
||||
@@ -39,10 +39,13 @@ const SessionFile = (props: SessionFileProps) => {
|
||||
if (isTauri) {
|
||||
console.log("sessionId", sessionId);
|
||||
|
||||
const response: any = await platformAdapter.commands("get_attachment", {
|
||||
serverId,
|
||||
sessionId,
|
||||
});
|
||||
const response: any = await platformAdapter.commands(
|
||||
"get_attachment_by_ids",
|
||||
{
|
||||
serverId,
|
||||
sessionId,
|
||||
}
|
||||
);
|
||||
|
||||
setUploadedFiles(response?.hits?.hits ?? []);
|
||||
} else {
|
||||
@@ -145,9 +148,7 @@ const SessionFile = (props: SessionFileProps) => {
|
||||
</div>
|
||||
<div className="text-xs text-[#999]">
|
||||
{icon && <span className="pr-2">{icon}</span>}
|
||||
<span>
|
||||
{filesize(size, { standard: "jedec", spacer: "" })}
|
||||
</span>
|
||||
<span>{filesize(size)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -54,7 +54,7 @@ const Splash = ({ assistantIDs = [], startPage }: SplashProps) => {
|
||||
|
||||
let response: any;
|
||||
if (isTauri) {
|
||||
if (unrequitable()) {
|
||||
if (await unrequitable()) {
|
||||
return setVisibleStartPage(false);
|
||||
}
|
||||
|
||||
@@ -72,6 +72,8 @@ const Splash = ({ assistantIDs = [], startPage }: SplashProps) => {
|
||||
setSettings(response);
|
||||
};
|
||||
|
||||
console.log("currentService", currentService);
|
||||
|
||||
useEffect(() => {
|
||||
getSettings();
|
||||
fetchData();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import dayjs from "dayjs";
|
||||
import durationPlugin from "dayjs/plugin/duration";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
import { useThemeStore } from "@/stores/themeStore";
|
||||
import loadingLight from "@/assets/images/ReadAloud/loading-light.png";
|
||||
@@ -18,7 +19,6 @@ import closeDark from "@/assets/images/ReadAloud/close-dark.png";
|
||||
import { useConnectStore } from "@/stores/connectStore";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
import { useStreamAudio } from "@/hooks/useStreamAudio";
|
||||
import { nanoid } from "nanoid";
|
||||
import { useChatStore } from "@/stores/chatStore";
|
||||
|
||||
dayjs.extend(durationPlugin);
|
||||
|
||||
@@ -82,8 +82,6 @@ const AudioRecording: FC<AudioRecordingProps> = (props) => {
|
||||
}
|
||||
);
|
||||
|
||||
console.log("response", response);
|
||||
|
||||
const text = response?.results
|
||||
.flatMap((item: any) => item?.transcription?.transcripts)
|
||||
.map((item: any) => item?.text?.replace(/<\|[\/\w]+\|>/g, ""))
|
||||
@@ -161,7 +159,7 @@ const AudioRecording: FC<AudioRecordingProps> = (props) => {
|
||||
<>
|
||||
<div
|
||||
className={clsx(
|
||||
"size-6 flex items-center justify-center hover:bg-gray-50 dark:hover:bg-gray-700 rounded-full transition cursor-pointer",
|
||||
"min-w-6 h-6 flex items-center justify-center hover:bg-gray-50 dark:hover:bg-gray-700 rounded-full transition cursor-pointer",
|
||||
{
|
||||
hidden: state.audioDevices.length === 0,
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ export const MessageActions = ({
|
||||
};
|
||||
|
||||
const handleSpeak = async () => {
|
||||
if (isDefaultServer()) {
|
||||
if (await isDefaultServer()) {
|
||||
return setSynthesizeItem({ id, content });
|
||||
}
|
||||
|
||||
|
||||
@@ -1,17 +1,28 @@
|
||||
import { useState } from "react";
|
||||
import { FC, useState } from "react";
|
||||
import clsx from "clsx";
|
||||
|
||||
import { CopyButton } from "@/components/Common/CopyButton";
|
||||
import { useAsyncEffect } from "ahooks";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
import { useConnectStore } from "@/stores/connectStore";
|
||||
import { AttachmentItem } from "../Assistant/AttachmentList";
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
|
||||
interface UserMessageProps {
|
||||
messageContent: string;
|
||||
message: string;
|
||||
attachments: string[];
|
||||
}
|
||||
|
||||
export const UserMessage = ({ messageContent }: UserMessageProps) => {
|
||||
export const UserMessage: FC<UserMessageProps> = (props) => {
|
||||
const { message, attachments } = props;
|
||||
|
||||
const [showCopyButton, setShowCopyButton] = useState(false);
|
||||
const { currentService } = useConnectStore();
|
||||
const [attachmentData, setAttachmentData] = useState<any[]>([]);
|
||||
const { addError } = useAppStore();
|
||||
|
||||
const handleDoubleClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
|
||||
if (typeof window !== "undefined" && typeof document !== "undefined") {
|
||||
const selection = window.getSelection();
|
||||
const range = document.createRange();
|
||||
|
||||
@@ -21,31 +32,81 @@ export const UserMessage = ({ messageContent }: UserMessageProps) => {
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
} catch (error) {
|
||||
console.error('Selection failed:', error);
|
||||
console.error("Selection failed:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useAsyncEffect(async () => {
|
||||
try {
|
||||
if (attachments.length === 0) return;
|
||||
|
||||
const result: any = await platformAdapter.commands(
|
||||
"get_attachment_by_ids",
|
||||
{
|
||||
serverId: currentService.id,
|
||||
attachments,
|
||||
}
|
||||
);
|
||||
|
||||
setAttachmentData(result?.hits?.hits);
|
||||
} catch (error) {
|
||||
addError(String(error));
|
||||
}
|
||||
}, [attachments]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="max-w-full flex gap-1 items-center justify-end"
|
||||
onMouseEnter={() => setShowCopyButton(true)}
|
||||
onMouseLeave={() => setShowCopyButton(false)}
|
||||
>
|
||||
<div
|
||||
className={clsx("size-6 transition", {
|
||||
"opacity-0": !showCopyButton,
|
||||
})}
|
||||
>
|
||||
<CopyButton textToCopy={messageContent} />
|
||||
</div>
|
||||
<div
|
||||
className="max-w-[85%] overflow-auto text-left px-3 py-2 bg-white dark:bg-[#202126] rounded-xl border border-black/12 dark:border-black/15 font-normal text-sm text-[#333333] dark:text-[#D8D8D8] cursor-pointer user-select-text whitespace-pre-wrap"
|
||||
onDoubleClick={handleDoubleClick}
|
||||
>
|
||||
{messageContent}
|
||||
</div>
|
||||
</div>
|
||||
<>
|
||||
{message && (
|
||||
<div
|
||||
className="flex gap-1 items-center justify-end"
|
||||
onMouseEnter={() => setShowCopyButton(true)}
|
||||
onMouseLeave={() => setShowCopyButton(false)}
|
||||
>
|
||||
<div
|
||||
className={clsx("size-6 transition", {
|
||||
"opacity-0": !showCopyButton,
|
||||
})}
|
||||
>
|
||||
<CopyButton textToCopy={message} />
|
||||
</div>
|
||||
<div
|
||||
className="max-w-[85%] overflow-auto text-left px-3 py-2 bg-white dark:bg-[#202126] rounded-xl border border-black/12 dark:border-black/15 font-normal text-sm text-[#333333] dark:text-[#D8D8D8] cursor-pointer user-select-text whitespace-pre-wrap"
|
||||
onDoubleClick={handleDoubleClick}
|
||||
>
|
||||
{message}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{attachmentData && (
|
||||
<div
|
||||
className={clsx("flex justify-end flex-wrap gap-y-2 w-full", {
|
||||
"mt-3": message,
|
||||
})}
|
||||
>
|
||||
{attachmentData.map((item) => {
|
||||
const { id, name, size, icon } = item._source;
|
||||
|
||||
return (
|
||||
<AttachmentItem
|
||||
{...item._source}
|
||||
key={id}
|
||||
uploading={false}
|
||||
uploaded
|
||||
id={id}
|
||||
extname={icon}
|
||||
attachmentId={id}
|
||||
name={name}
|
||||
path={name}
|
||||
size={size}
|
||||
deletable={false}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -89,6 +89,7 @@ export const ChatMessage = memo(function ChatMessage({
|
||||
]);
|
||||
|
||||
const messageContent = message?._source?.message || "";
|
||||
const attachments = message?._source?.attachments ?? [];
|
||||
const details = message?._source?.details || [];
|
||||
const question = message?._source?.question || "";
|
||||
|
||||
@@ -103,7 +104,7 @@ export const ChatMessage = memo(function ChatMessage({
|
||||
|
||||
const renderContent = () => {
|
||||
if (!isAssistant) {
|
||||
return <UserMessage messageContent={messageContent} />;
|
||||
return <UserMessage message={messageContent} attachments={attachments} />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef, useState, useCallback } from "react";
|
||||
import { emit } from "@tauri-apps/api/event";
|
||||
import { isEqual } from "lodash-es";
|
||||
import { usePrevious } from "ahooks";
|
||||
|
||||
import { DataSourcesList } from "./DataSourcesList";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
@@ -8,6 +9,8 @@ import { useAppStore } from "@/stores/appStore";
|
||||
import { useConnectStore } from "@/stores/connectStore";
|
||||
import ServiceInfo from "./ServiceInfo";
|
||||
import ServiceAuth from "./ServiceAuth";
|
||||
import type { Server } from "@/types/server";
|
||||
import { useServers } from "@/hooks/useServers";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
|
||||
export default function Cloud() {
|
||||
@@ -17,100 +20,72 @@ export default function Cloud() {
|
||||
|
||||
const [isConnect, setIsConnect] = useState(true);
|
||||
|
||||
const { currentService, setCurrentService, serverList, setServerList } =
|
||||
useConnectStore();
|
||||
const {
|
||||
cloudSelectService,
|
||||
setCloudSelectService,
|
||||
serverList,
|
||||
setServerList,
|
||||
} = useConnectStore();
|
||||
const prevServerList = usePrevious(serverList);
|
||||
|
||||
const [refreshLoading, setRefreshLoading] = useState(false);
|
||||
|
||||
const { addServer, refreshServerList } = useServers();
|
||||
|
||||
// fetch the servers
|
||||
useEffect(() => {
|
||||
fetchServers(true);
|
||||
}, []);
|
||||
if (isEqual(prevServerList, serverList)) return;
|
||||
|
||||
fetchServers();
|
||||
}, [serverList]);
|
||||
|
||||
useEffect(() => {
|
||||
// console.log("currentService", currentService);
|
||||
setRefreshLoading(false);
|
||||
setIsConnect(true);
|
||||
}, [JSON.stringify(currentService)]);
|
||||
}, [cloudSelectService?.id]);
|
||||
|
||||
const fetchServers = async (resetSelection: boolean) => {
|
||||
platformAdapter
|
||||
.commands("list_coco_servers")
|
||||
.then((res: any) => {
|
||||
if (errors.length > 0) {
|
||||
res = (res || []).map((item: any) => {
|
||||
if (item.id === currentService?.id) {
|
||||
item.health = {
|
||||
services: null,
|
||||
status: null,
|
||||
};
|
||||
}
|
||||
return item;
|
||||
});
|
||||
const fetchServers = useCallback(async () => {
|
||||
let { serverList } = useConnectStore.getState();
|
||||
|
||||
if (errors.length > 0) {
|
||||
serverList = serverList.map((item: Server) => {
|
||||
if (item.id === cloudSelectService?.id) {
|
||||
return {
|
||||
...item,
|
||||
health: {
|
||||
services: item.health?.services || {},
|
||||
status: item.health?.status || "red",
|
||||
},
|
||||
};
|
||||
}
|
||||
console.log("list_coco_servers", res);
|
||||
setServerList(res);
|
||||
|
||||
if (resetSelection && res.length > 0) {
|
||||
const matched = res.find((server: any) => {
|
||||
return server.id === currentService?.id;
|
||||
});
|
||||
|
||||
if (matched) {
|
||||
setCurrentService(matched);
|
||||
} else {
|
||||
setCurrentService(res[res.length - 1]);
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
const addServer = (endpointLink: string) => {
|
||||
if (!endpointLink) {
|
||||
throw new Error("Endpoint is required");
|
||||
}
|
||||
if (
|
||||
!endpointLink.startsWith("http://") &&
|
||||
!endpointLink.startsWith("https://")
|
||||
) {
|
||||
throw new Error("Invalid Endpoint");
|
||||
}
|
||||
|
||||
setRefreshLoading(true);
|
||||
|
||||
return platformAdapter
|
||||
.commands("add_coco_server", endpointLink)
|
||||
.then((res: any) => {
|
||||
// console.log("add_coco_server", res);
|
||||
fetchServers(false).then((r) => {
|
||||
console.log("fetchServers", r);
|
||||
setCurrentService(res);
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
setRefreshLoading(false);
|
||||
return item;
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
setServerList(serverList);
|
||||
|
||||
if (serverList.length > 0) {
|
||||
const matched = serverList.find((server: any) => {
|
||||
return server.id === cloudSelectService?.id;
|
||||
});
|
||||
|
||||
if (matched) {
|
||||
setCloudSelectService(matched);
|
||||
} else {
|
||||
setCloudSelectService(serverList[serverList.length - 1]);
|
||||
}
|
||||
}
|
||||
}, [errors, cloudSelectService]);
|
||||
|
||||
const refreshClick = useCallback(
|
||||
(id: string) => {
|
||||
async (id: string, callback?: () => void) => {
|
||||
setRefreshLoading(true);
|
||||
platformAdapter
|
||||
.commands("refresh_coco_server_info", id)
|
||||
.then((res: any) => {
|
||||
console.log("refresh_coco_server_info", id, res);
|
||||
fetchServers(false).then((r) => {
|
||||
console.log("fetchServers", r);
|
||||
});
|
||||
// update currentService
|
||||
setCurrentService(res);
|
||||
emit("login_or_logout", true);
|
||||
})
|
||||
.finally(() => {
|
||||
setRefreshLoading(false);
|
||||
});
|
||||
await platformAdapter.commands("refresh_coco_server_info", id);
|
||||
await refreshServerList();
|
||||
setRefreshLoading(false);
|
||||
callback && callback();
|
||||
},
|
||||
[fetchServers]
|
||||
[refreshServerList]
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -127,7 +102,6 @@ export default function Cloud() {
|
||||
<ServiceInfo
|
||||
refreshLoading={refreshLoading}
|
||||
refreshClick={refreshClick}
|
||||
fetchServers={fetchServers}
|
||||
/>
|
||||
|
||||
<ServiceAuth
|
||||
@@ -135,8 +109,8 @@ export default function Cloud() {
|
||||
refreshClick={refreshClick}
|
||||
/>
|
||||
|
||||
{currentService?.profile && currentService?.available ? (
|
||||
<DataSourcesList server={currentService?.id} />
|
||||
{cloudSelectService?.profile && cloudSelectService?.available ? (
|
||||
<DataSourcesList server={cloudSelectService?.id} />
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -21,7 +21,7 @@ export function Connect({ setIsConnect, onAddServer }: ConnectServiceProps) {
|
||||
};
|
||||
|
||||
const onAddServerClick = async (endpoint: string) => {
|
||||
console.log("onAddServer", endpoint);
|
||||
//console.log("onAddServer", endpoint);
|
||||
await onAddServer(endpoint);
|
||||
setIsConnect(true);
|
||||
};
|
||||
|
||||
@@ -20,7 +20,6 @@ export function DataSourcesList({ server }: { server: string }) {
|
||||
platformAdapter
|
||||
.commands("get_connectors_by_server", server)
|
||||
.then((res: any) => {
|
||||
// console.log("get_connectors_by_server", res);
|
||||
setConnectorData(res, server);
|
||||
})
|
||||
.finally(() => {});
|
||||
@@ -29,7 +28,6 @@ export function DataSourcesList({ server }: { server: string }) {
|
||||
platformAdapter
|
||||
.commands("datasource_search", { id: server })
|
||||
.then((res: any) => {
|
||||
// console.log("datasource_search", res);
|
||||
setDatasourceData(res, server);
|
||||
})
|
||||
.finally(() => {
|
||||
|
||||
@@ -2,11 +2,6 @@ import { FC, memo, useCallback, useEffect, useState } from "react";
|
||||
import { Copy } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import {
|
||||
getCurrent as getCurrentDeepLinkUrls,
|
||||
onOpenUrl,
|
||||
} from "@tauri-apps/plugin-deep-link";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
|
||||
import { UserProfile } from "./UserProfile";
|
||||
import { OpenURLWithBrowser } from "@/utils";
|
||||
@@ -14,23 +9,27 @@ import { useConnectStore } from "@/stores/connectStore";
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
import { copyToClipboard } from "@/utils";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
import { handleLogout } from "@/commands/servers";
|
||||
import { useServers } from "@/hooks/useServers";
|
||||
|
||||
interface ServiceAuthProps {
|
||||
setRefreshLoading: (loading: boolean) => void;
|
||||
refreshClick: (id: string) => void;
|
||||
refreshClick: (id: string, callback?: () => void) => void;
|
||||
}
|
||||
|
||||
const ServiceAuth = memo(
|
||||
({ setRefreshLoading, refreshClick }: ServiceAuthProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const language = useAppStore((state) => state.language);
|
||||
const addError = useAppStore((state) => state.addError);
|
||||
const ssoRequestID = useAppStore((state) => state.ssoRequestID);
|
||||
const setSSORequestID = useAppStore((state) => state.setSSORequestID);
|
||||
|
||||
const addError = useAppStore((state) => state.addError);
|
||||
const cloudSelectService = useConnectStore(
|
||||
(state) => state.cloudSelectService
|
||||
);
|
||||
|
||||
const currentService = useConnectStore((state) => state.currentService);
|
||||
const { logoutServer } = useServers();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
@@ -41,130 +40,52 @@ const ServiceAuth = memo(
|
||||
setSSORequestID(requestID);
|
||||
|
||||
// Generate the login URL with the current appUid
|
||||
const url = `${currentService?.auth_provider?.sso?.url}/?provider=${currentService?.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
|
||||
OpenURLWithBrowser(url);
|
||||
|
||||
// Start loading state
|
||||
setLoading(true);
|
||||
}, [ssoRequestID, loading, currentService]);
|
||||
}, [ssoRequestID, loading, cloudSelectService]);
|
||||
|
||||
const onLogout = useCallback((id: string) => {
|
||||
setRefreshLoading(true);
|
||||
platformAdapter
|
||||
.commands("logout_coco_server", id)
|
||||
.then((res: any) => {
|
||||
console.log("logout_coco_server", id, JSON.stringify(res));
|
||||
handleLogout(id);
|
||||
})
|
||||
.finally(() => {
|
||||
const onLogout = useCallback(
|
||||
(id: string) => {
|
||||
setRefreshLoading(true);
|
||||
logoutServer(id).finally(() => {
|
||||
setRefreshLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleOAuthCallback = useCallback(
|
||||
async (code: string | null, serverId: string | null) => {
|
||||
if (!code || !serverId) {
|
||||
addError("No authorization code received");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log("Handling OAuth callback:", { code, serverId });
|
||||
await platformAdapter.commands("handle_sso_callback", {
|
||||
serverId: serverId, // Make sure 'server_id' is the correct argument
|
||||
requestId: ssoRequestID, // Make sure 'request_id' is the correct argument
|
||||
code: code,
|
||||
});
|
||||
|
||||
if (serverId != null) {
|
||||
refreshClick(serverId);
|
||||
}
|
||||
|
||||
getCurrentWindow().setFocus();
|
||||
} catch (e) {
|
||||
console.error("Sign in failed:", e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[ssoRequestID]
|
||||
[logoutServer]
|
||||
);
|
||||
|
||||
const handleUrl = (url: string) => {
|
||||
try {
|
||||
const urlObject = new URL(url.trim());
|
||||
console.log("handle urlObject:", urlObject);
|
||||
|
||||
// pass request_id and check with local, if the request_id are same, then continue
|
||||
const reqId = urlObject.searchParams.get("request_id");
|
||||
const code = urlObject.searchParams.get("code");
|
||||
|
||||
if (reqId != ssoRequestID) {
|
||||
console.log("Request ID not matched, skip");
|
||||
addError("Request ID not matched, skip");
|
||||
return;
|
||||
}
|
||||
|
||||
const serverId = currentService?.id;
|
||||
handleOAuthCallback(code, serverId);
|
||||
} catch (err) {
|
||||
console.error("Failed to parse URL:", err);
|
||||
addError("Invalid URL format: " + err);
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch the initial deep link intent
|
||||
// handle oauth success event
|
||||
useEffect(() => {
|
||||
// Function to handle pasted URL
|
||||
const handlePaste = (event: any) => {
|
||||
const pastedText = event.clipboardData.getData("text").trim();
|
||||
console.log("handle paste text:", pastedText);
|
||||
if (isValidCallbackUrl(pastedText)) {
|
||||
// Handle the URL as if it's a deep link
|
||||
console.log("handle callback on paste:", pastedText);
|
||||
handleUrl(pastedText);
|
||||
}
|
||||
};
|
||||
|
||||
// Function to check if the pasted URL is valid for our deep link scheme
|
||||
const isValidCallbackUrl = (url: string) => {
|
||||
return url && url.startsWith("coco://oauth_callback");
|
||||
};
|
||||
|
||||
// Adding event listener for paste events
|
||||
document.addEventListener("paste", handlePaste);
|
||||
|
||||
getCurrentDeepLinkUrls()
|
||||
.then((urls) => {
|
||||
console.log("URLs:", urls);
|
||||
if (urls && urls.length > 0) {
|
||||
if (isValidCallbackUrl(urls[0].trim())) {
|
||||
handleUrl(urls[0]);
|
||||
}
|
||||
const unlistenOAuth = platformAdapter.listenEvent(
|
||||
"oauth_success",
|
||||
(event) => {
|
||||
const { serverId } = event.payload;
|
||||
if (serverId) {
|
||||
refreshClick(serverId, () => {
|
||||
setLoading(false);
|
||||
});
|
||||
addError(language === "zh" ? "登录成功" : "Login Success", "info");
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Failed to get initial URLs:", err);
|
||||
addError("Failed to get initial URLs: " + err);
|
||||
});
|
||||
|
||||
const unlisten = onOpenUrl((urls) => handleUrl(urls[0]));
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
unlisten.then((fn) => fn());
|
||||
document.removeEventListener("paste", handlePaste);
|
||||
unlistenOAuth.then((fn) => fn());
|
||||
};
|
||||
}, [ssoRequestID]);
|
||||
}, [refreshClick]);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(false);
|
||||
}, [currentService]);
|
||||
}, [cloudSelectService]);
|
||||
|
||||
if (!currentService?.auth_provider?.sso?.url) {
|
||||
if (!cloudSelectService?.auth_provider?.sso?.url) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -173,10 +94,10 @@ const ServiceAuth = memo(
|
||||
<h2 className="text-lg font-medium text-gray-900 dark:text-white mb-4">
|
||||
{t("cloud.accountInfo")}
|
||||
</h2>
|
||||
{currentService?.profile ? (
|
||||
{cloudSelectService?.profile ? (
|
||||
<UserProfile
|
||||
server={currentService?.id}
|
||||
userInfo={currentService?.profile}
|
||||
server={cloudSelectService?.id}
|
||||
userInfo={cloudSelectService?.profile}
|
||||
onLogout={onLogout}
|
||||
/>
|
||||
) : (
|
||||
@@ -190,7 +111,7 @@ const ServiceAuth = memo(
|
||||
onCancel={() => setLoading(false)}
|
||||
onCopy={() => {
|
||||
copyToClipboard(
|
||||
`${currentService?.auth_provider?.sso?.url}/?provider=${currentService?.id}&product=coco&request_id=${ssoRequestID}`
|
||||
`${cloudSelectService?.auth_provider?.sso?.url}/?provider=${cloudSelectService?.id}&product=coco&request_id=${ssoRequestID}`
|
||||
);
|
||||
}}
|
||||
/>
|
||||
@@ -201,7 +122,7 @@ const ServiceAuth = memo(
|
||||
<button
|
||||
className="text-xs text-[#0096FB] dark:text-blue-400 block"
|
||||
onClick={() =>
|
||||
OpenURLWithBrowser(currentService?.provider?.eula)
|
||||
OpenURLWithBrowser(cloudSelectService?.provider?.eula)
|
||||
}
|
||||
>
|
||||
{t("cloud.eula")}
|
||||
@@ -215,7 +136,9 @@ const ServiceAuth = memo(
|
||||
<button
|
||||
className="text-xs text-[#0096FB] dark:text-blue-400 block"
|
||||
onClick={() =>
|
||||
OpenURLWithBrowser(currentService?.provider?.privacy_policy)
|
||||
OpenURLWithBrowser(
|
||||
cloudSelectService?.provider?.privacy_policy
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("cloud.privacyPolicy")}
|
||||
|
||||
@@ -6,13 +6,13 @@ import { useConnectStore } from "@/stores/connectStore";
|
||||
interface ServiceBannerProps {}
|
||||
|
||||
const ServiceBanner = memo(({}: ServiceBannerProps) => {
|
||||
const currentService = useConnectStore((state) => state.currentService);
|
||||
const cloudSelectService = useConnectStore((state) => state.cloudSelectService);
|
||||
|
||||
return (
|
||||
<div className="w-full rounded-[4px] bg-[rgba(229,229,229,1)] dark:bg-gray-800 mb-6">
|
||||
<img
|
||||
width="100%"
|
||||
src={currentService?.provider?.banner || bannerImg}
|
||||
src={cloudSelectService?.provider?.banner || bannerImg}
|
||||
alt="banner"
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user