mirror of
https://github.com/infinilabs/coco-app.git
synced 2025-12-18 20:39:25 +01:00
Compare commits
53 Commits
microphone
...
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 |
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
|
||||
|
||||
|
||||
@@ -8,19 +8,45 @@ title: "Release Notes"
|
||||
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
|
||||
|
||||
@@ -30,6 +56,15 @@ Information about release notes of Coco App is provided here.
|
||||
- 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)
|
||||
|
||||
|
||||
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.1",
|
||||
"version": "0.8.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -26,7 +26,7 @@
|
||||
"@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",
|
||||
@@ -93,4 +93,4 @@
|
||||
"vite": "^5.4.19"
|
||||
},
|
||||
"packageManager": "pnpm@10.11.0+sha512.6540583f41cc5f628eb3d9773ecee802f4f9ef9923cc45b69890fb47991d4b092964694ec3a4f738a420c918a333062c8b925d312f42e4f0c263eb603551f977"
|
||||
}
|
||||
}
|
||||
|
||||
63
pnpm-lock.yaml
generated
63
pnpm-lock.yaml
generated
@@ -33,8 +33,8 @@ importers:
|
||||
specifier: ~2.4.0
|
||||
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
|
||||
@@ -790,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'}
|
||||
@@ -802,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==}
|
||||
|
||||
@@ -1153,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'}
|
||||
@@ -1242,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==}
|
||||
@@ -1464,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'}
|
||||
@@ -4230,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
|
||||
@@ -4240,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
|
||||
@@ -4546,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
|
||||
|
||||
@@ -4617,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:
|
||||
@@ -4876,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):
|
||||
@@ -7232,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
|
||||
|
||||
2855
src-tauri/Cargo.lock
generated
2855
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.1"
|
||||
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"]
|
||||
@@ -61,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"] }
|
||||
@@ -103,17 +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" }
|
||||
cocoa = "0.24"
|
||||
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.
|
||||
@@ -134,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.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,6 +69,7 @@
|
||||
"updater:default",
|
||||
"windows-version:default",
|
||||
"log:default",
|
||||
"opener:default"
|
||||
"opener:default",
|
||||
"core:window:allow-unminimize"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
use std::{fs::create_dir, io::Read};
|
||||
|
||||
use tauri::Manager;
|
||||
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!(
|
||||
|
||||
@@ -1,6 +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::{AppHandle, Emitter};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RichLabel {
|
||||
@@ -30,17 +33,48 @@ 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.
|
||||
@@ -50,6 +84,12 @@ pub(crate) enum OnOpened {
|
||||
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 {
|
||||
@@ -57,20 +97,33 @@ 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());
|
||||
}
|
||||
|
||||
ret
|
||||
#[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
|
||||
}
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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".
|
||||
Self::Quicklink { .. } => String::from("N/A"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -95,65 +148,106 @@ pub(crate) async fn open(
|
||||
|
||||
homemade_tauri_shell_open(tauri_app_handle.clone(), url).await?
|
||||
}
|
||||
OnOpened::Command { action } => {
|
||||
log::debug!("open (execute) command [{:?}]", action);
|
||||
#[cfg(target_os = "macos")]
|
||||
OnOpened::WindowManagementAction { action } => {
|
||||
log::debug!("perform Window Management action [{:?}]", 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)
|
||||
));
|
||||
}
|
||||
crate::extension::built_in::window_management::perform_action_on_main_thread(
|
||||
&tauri_app_handle,
|
||||
action,
|
||||
)?;
|
||||
}
|
||||
OnOpened::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());
|
||||
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;
|
||||
}
|
||||
cmd.arg(&url);
|
||||
}
|
||||
}
|
||||
let permission = ext_on_opened.permission;
|
||||
|
||||
let output = cmd.output().map_err(|e| format!("failed to spawn [open] due to error [{}]", e))?;
|
||||
match ext_on_opened.ty {
|
||||
ExtensionOnOpenedType::Command { action } => {
|
||||
log::debug!("open (execute) command [{:?}]", action);
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(format!(
|
||||
"failed to open with app {:?}: {}",
|
||||
opt_open_with,
|
||||
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)
|
||||
));
|
||||
}
|
||||
} else {
|
||||
homemade_tauri_shell_open(tauri_app_handle.clone(), url).await?
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -28,7 +28,7 @@ use serde_json::Value as Json;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use tauri::{AppHandle, Manager, async_runtime};
|
||||
use tauri_plugin_fs_pro::{IconOptions, icon, metadata, name};
|
||||
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,16 +102,56 @@ 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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,9 +248,17 @@ async fn index_applications_if_not_indexed(
|
||||
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
|
||||
@@ -268,21 +303,39 @@ async fn index_applications_if_not_indexed(
|
||||
|
||||
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,
|
||||
}
|
||||
@@ -291,8 +344,8 @@ async fn index_applications_if_not_indexed(
|
||||
// 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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -421,9 +474,19 @@ impl Task for SearchApplicationsTask {
|
||||
//
|
||||
// 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
|
||||
@@ -620,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,
|
||||
@@ -630,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();
|
||||
};
|
||||
@@ -641,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,
|
||||
@@ -664,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),
|
||||
@@ -1052,15 +1150,24 @@ pub async fn get_app_search_path(tauri_app_handle: AppHandle) -> Vec<String> {
|
||||
|
||||
#[tauri::command]
|
||||
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;
|
||||
}
|
||||
@@ -1128,11 +1235,14 @@ pub async fn get_app_list(tauri_app_handle: AppHandle) -> Result<Vec<Extension>,
|
||||
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,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! 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;
|
||||
@@ -23,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,
|
||||
@@ -197,13 +198,19 @@ pub async fn set_file_system_config(
|
||||
.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,6 +16,8 @@ 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};
|
||||
|
||||
@@ -173,17 +176,26 @@ pub(crate) async fn list_built_in_extensions(
|
||||
.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)
|
||||
@@ -212,16 +224,28 @@ pub(super) async fn init_built_in_extension(
|
||||
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(())
|
||||
@@ -299,19 +323,51 @@ pub(crate) async fn enable_built_in_extension(
|
||||
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)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -387,18 +443,44 @@ pub(crate) async fn disable_built_in_extension(
|
||||
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)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -410,12 +492,32 @@ 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(
|
||||
@@ -428,6 +530,29 @@ pub(crate) fn register_built_in_extension_hotkey(
|
||||
application::register_app_hotkey(&tauri_app_handle, app_path, hotkey)?;
|
||||
}
|
||||
}
|
||||
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(target_os = "macos")] {
|
||||
let update_function = |ext: &mut Extension| {
|
||||
ext.hotkey = Some(hotkey.into());
|
||||
|
||||
Ok(())
|
||||
};
|
||||
|
||||
if bundle_id.extension_id == window_management::EXTENSION_ID {
|
||||
if let Some(command_id) = bundle_id.sub_extension_id {
|
||||
alter_extension_json_file(
|
||||
&get_built_in_extension_directory(tauri_app_handle),
|
||||
bundle_id,
|
||||
update_function,
|
||||
)?;
|
||||
|
||||
window_management::register_command_hotkey(tauri_app_handle, command_id, hotkey)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -440,6 +565,35 @@ pub(crate) fn unregister_built_in_extension_hotkey(
|
||||
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(())
|
||||
}
|
||||
|
||||
@@ -479,6 +633,8 @@ fn load_extension_from_json_file(
|
||||
Ok(extension)
|
||||
}
|
||||
|
||||
#[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<'_>,
|
||||
@@ -524,9 +680,17 @@ pub(crate) async fn is_built_in_extension_enabled(
|
||||
return Ok(extension.enabled);
|
||||
}
|
||||
|
||||
if bundle_id.extension_id == file_search::EXTENSION_ID && bundle_id.sub_extension_id.is_none() {
|
||||
return Ok(search_source_registry_tauri_state
|
||||
.get_source(bundle_id.extension_id)
|
||||
.await
|
||||
.is_some());
|
||||
}
|
||||
|
||||
cfg_if::cfg_if! {
|
||||
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
|
||||
@@ -534,6 +698,25 @@ pub(crate) async fn is_built_in_extension_enabled(
|
||||
.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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,14 @@
|
||||
pub(crate) mod api;
|
||||
pub(crate) mod built_in;
|
||||
pub(crate) mod third_party;
|
||||
|
||||
use crate::common::document::ExtensionOnOpened;
|
||||
use crate::common::document::ExtensionOnOpenedType;
|
||||
use crate::common::document::OnOpened;
|
||||
use crate::common::register::SearchSourceRegistry;
|
||||
use crate::util::platform::Platform;
|
||||
use anyhow::Context;
|
||||
use bitflags::bitflags;
|
||||
use borrowme::{Borrow, ToOwned};
|
||||
use derive_more::Display;
|
||||
use indexmap::IndexMap;
|
||||
@@ -76,11 +80,14 @@ pub struct Extension {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
quicklink: Option<Quicklink>,
|
||||
|
||||
// If this extension is of type Group or Extension, then it behaves like a
|
||||
// directory, i.e., it could contain sub items.
|
||||
/*
|
||||
* If this extension is of type Group or Extension, then it behaves like a
|
||||
* directory, i.e., it could contain sub items.
|
||||
*/
|
||||
commands: Option<Vec<Extension>>,
|
||||
scripts: Option<Vec<Extension>>,
|
||||
quicklinks: Option<Vec<Extension>>,
|
||||
views: Option<Vec<Extension>>,
|
||||
|
||||
/// The alias of the extension.
|
||||
///
|
||||
@@ -99,9 +106,21 @@ pub struct Extension {
|
||||
|
||||
/// Extension settings
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
settings: Option<Json>,
|
||||
settings: Option<ExtensionSettings>,
|
||||
|
||||
// We do not care about these fields, just take it regardless of what it is.
|
||||
/// For View extensions, path to the HTML file/page that coco will load
|
||||
/// and render. Otherwise, `None`.
|
||||
page: Option<String>,
|
||||
|
||||
/// Permission that this extension requires.
|
||||
permission: Option<ExtensionPermission>,
|
||||
|
||||
/*
|
||||
* The following fields are currently useless to us but are needed by our
|
||||
* extension store.
|
||||
*
|
||||
* Since we do not use them, just accept them regardless of what they are.
|
||||
*/
|
||||
screenshots: Option<Json>,
|
||||
url: Option<Json>,
|
||||
version: Option<Json>,
|
||||
@@ -164,37 +183,62 @@ impl<'ext> PartialEq<ExtensionBundleId> for ExtensionBundleIdBorrowed<'ext> {
|
||||
}
|
||||
|
||||
impl Extension {
|
||||
/// WARNING: the bundle ID returned from this function always has its `sub_extension_id`
|
||||
/// set to `None`, this may not be what you want.
|
||||
pub(crate) fn bundle_id_borrowed(&self) -> ExtensionBundleIdBorrowed<'_> {
|
||||
ExtensionBundleIdBorrowed {
|
||||
developer: self.developer.as_deref(),
|
||||
extension_id: &self.id,
|
||||
sub_extension_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this extension could be searched.
|
||||
pub(crate) fn searchable(&self) -> bool {
|
||||
self.on_opened().is_some()
|
||||
}
|
||||
|
||||
/// Return what will happen when we open this extension.
|
||||
///
|
||||
/// `None` if it cannot be opened.
|
||||
pub(crate) fn on_opened(&self) -> Option<OnOpened> {
|
||||
let settings = self.settings.clone();
|
||||
let permission = self.permission.clone();
|
||||
|
||||
match self.r#type {
|
||||
ExtensionType::Group => None,
|
||||
ExtensionType::Extension => None,
|
||||
ExtensionType::Command => Some(OnOpened::Command {
|
||||
action: self.action.clone().unwrap_or_else(|| {
|
||||
panic!(
|
||||
"Command extension [{}]'s [action] field is not set, something wrong with your extension validity check", self.id
|
||||
)
|
||||
}),
|
||||
}),
|
||||
ExtensionType::Application => Some(OnOpened::Application {
|
||||
app_path: self.id.clone(),
|
||||
}),
|
||||
// This function, at the time of writing this comment, is primarily
|
||||
// used by third-party extensions.
|
||||
//
|
||||
// Built-in extensions don't use this as they are technically not
|
||||
// "struct Extension"s. Typically, they directly construct a
|
||||
// "struct Document" from their own type.
|
||||
ExtensionType::Calculator => unreachable!("this is handled by frontend"),
|
||||
ExtensionType::AiExtension => unreachable!(
|
||||
"currently, all AI extensions we have are non-searchable, so we won't open them"
|
||||
),
|
||||
ExtensionType::Application => {
|
||||
// We can have a impl like:
|
||||
//
|
||||
// Some(OnOpened::Application { app_path: self.id.clone() })
|
||||
//
|
||||
// but it won't be used.
|
||||
|
||||
unreachable!(
|
||||
"Applications are not \"struct Extension\" under the hood, they won't call this method"
|
||||
)
|
||||
}
|
||||
|
||||
// These 2 types of extensions cannot be opened
|
||||
ExtensionType::Group => return None,
|
||||
ExtensionType::Extension => return None,
|
||||
|
||||
ExtensionType::Command => {
|
||||
let ty = ExtensionOnOpenedType::Command {
|
||||
action: self.action.clone().unwrap_or_else(|| {
|
||||
panic!(
|
||||
"Command extension [{}]'s [action] field is not set, something wrong with your extension validity check", self.id
|
||||
)
|
||||
}),
|
||||
};
|
||||
|
||||
let extension_on_opened = ExtensionOnOpened {
|
||||
ty,
|
||||
settings,
|
||||
permission,
|
||||
};
|
||||
|
||||
Some(OnOpened::Extension(extension_on_opened))
|
||||
}
|
||||
ExtensionType::Quicklink => {
|
||||
let quicklink = self.quicklink.clone().unwrap_or_else(|| {
|
||||
panic!(
|
||||
@@ -202,15 +246,36 @@ impl Extension {
|
||||
)
|
||||
});
|
||||
|
||||
Some(OnOpened::Quicklink{
|
||||
link: quicklink.link,
|
||||
open_with: quicklink.open_with,
|
||||
})
|
||||
let ty = ExtensionOnOpenedType::Quicklink {
|
||||
link: quicklink.link,
|
||||
open_with: quicklink.open_with,
|
||||
};
|
||||
|
||||
let extension_on_opened = ExtensionOnOpened {
|
||||
ty,
|
||||
settings,
|
||||
permission,
|
||||
};
|
||||
|
||||
Some(OnOpened::Extension(extension_on_opened))
|
||||
}
|
||||
ExtensionType::Script => todo!("not supported yet"),
|
||||
ExtensionType::Setting => todo!("not supported yet"),
|
||||
ExtensionType::Calculator => None,
|
||||
ExtensionType::AiExtension => None,
|
||||
ExtensionType::View => {
|
||||
let page = self.page.as_ref().unwrap_or_else(|| {
|
||||
panic!("View extension [{}]'s [page] field is not set, something wrong with your extension validity check", self.id);
|
||||
}).clone();
|
||||
|
||||
let extension_on_opened_type = ExtensionOnOpenedType::View { page };
|
||||
let extension_on_opened = ExtensionOnOpened {
|
||||
ty: extension_on_opened_type,
|
||||
settings,
|
||||
permission,
|
||||
};
|
||||
let on_opened = OnOpened::Extension(extension_on_opened);
|
||||
|
||||
Some(on_opened)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,6 +299,11 @@ impl Extension {
|
||||
return Some(sub_ext);
|
||||
}
|
||||
}
|
||||
if let Some(ref views) = self.views {
|
||||
if let Some(sub_ext) = views.iter().find(|view| view.id == sub_extension_id) {
|
||||
return Some(sub_ext);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
@@ -264,6 +334,11 @@ impl Extension {
|
||||
return Some(sub_ext);
|
||||
}
|
||||
}
|
||||
if let Some(ref mut views) = self.views {
|
||||
if let Some(sub_ext) = views.iter_mut().find(|view| view.id == sub_extension_id) {
|
||||
return Some(sub_ext);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
@@ -473,6 +548,8 @@ pub enum ExtensionType {
|
||||
Calculator,
|
||||
#[display("AI Extension")]
|
||||
AiExtension,
|
||||
#[display("View")]
|
||||
View,
|
||||
}
|
||||
|
||||
impl ExtensionType {
|
||||
@@ -504,6 +581,9 @@ fn filter_out_extensions(
|
||||
if let Some(ref mut quicklinks) = extension.quicklinks {
|
||||
quicklinks.retain(|link| link.enabled);
|
||||
}
|
||||
if let Some(ref mut views) = extension.views {
|
||||
views.retain(|link| link.enabled);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -532,6 +612,9 @@ fn filter_out_extensions(
|
||||
if let Some(ref mut quicklinks) = extension.quicklinks {
|
||||
quicklinks.retain(|link| link.r#type == extension_type);
|
||||
}
|
||||
if let Some(ref mut views) = extension.views {
|
||||
views.retain(|link| link.r#type == extension_type);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -582,15 +665,14 @@ fn filter_out_extensions(
|
||||
if let Some(ref mut quicklinks) = extension.quicklinks {
|
||||
quicklinks.retain(&match_closure);
|
||||
}
|
||||
if let Some(ref mut views) = extension.views {
|
||||
views.retain(&match_closure);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Return value:
|
||||
///
|
||||
/// * boolean: indicates if we found any invalid extensions
|
||||
/// * Vec<Extension>: loaded extensions
|
||||
#[tauri::command]
|
||||
pub(crate) async fn list_extensions(
|
||||
tauri_app_handle: AppHandle,
|
||||
@@ -598,16 +680,11 @@ pub(crate) async fn list_extensions(
|
||||
extension_type: Option<ExtensionType>,
|
||||
list_enabled: bool,
|
||||
) -> Result<Vec<Extension>, String> {
|
||||
log::trace!("loading extensions");
|
||||
|
||||
let third_party_dir = third_party::get_third_party_extension_directory(&tauri_app_handle);
|
||||
if !third_party_dir.try_exists().map_err(|e| e.to_string())? {
|
||||
tokio::fs::create_dir_all(&third_party_dir)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
let mut third_party_extensions =
|
||||
third_party::list_third_party_extensions(&third_party_dir).await?;
|
||||
let mut third_party_extensions = third_party::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE
|
||||
.get()
|
||||
.expect("global third party extension search source not set")
|
||||
.extensions_snapshot()
|
||||
.await;
|
||||
|
||||
let built_in_extensions = built_in::list_built_in_extensions(&tauri_app_handle).await?;
|
||||
|
||||
@@ -661,31 +738,54 @@ pub(crate) async fn list_extensions(
|
||||
Ok(extensions)
|
||||
}
|
||||
|
||||
pub(crate) async fn init_extensions(
|
||||
tauri_app_handle: AppHandle,
|
||||
mut extensions: Vec<Extension>,
|
||||
) -> Result<(), String> {
|
||||
/// Initialize all the things that are related to extensions.
|
||||
pub(crate) async fn init_extensions(tauri_app_handle: &AppHandle) -> Result<(), String> {
|
||||
log::trace!("initializing extensions");
|
||||
|
||||
let search_source_registry_tauri_state = tauri_app_handle.state::<SearchSourceRegistry>();
|
||||
|
||||
// Third-party extensions
|
||||
//
|
||||
// 1. Init the global search source variable
|
||||
// 2. Init the extensions in search source
|
||||
// 3. Register the search source
|
||||
let third_party_dir = third_party::get_third_party_extension_directory(&tauri_app_handle);
|
||||
if !third_party_dir.try_exists().map_err(|e| e.to_string())? {
|
||||
tokio::fs::create_dir_all(&third_party_dir)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
let extensions =
|
||||
third_party::load_third_party_extensions_from_directory(&third_party_dir).await?;
|
||||
let search_source = third_party::ThirdPartyExtensionsSearchSource::new(extensions);
|
||||
search_source.init(&tauri_app_handle).await.unwrap();
|
||||
THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE
|
||||
.set(search_source.clone())
|
||||
.unwrap_or_else(|_already_set| {
|
||||
panic!(
|
||||
"while trying to set the global third party extension search source variable {}, we found it is already set, which should not happen",
|
||||
"THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE"
|
||||
)
|
||||
});
|
||||
search_source_registry_tauri_state
|
||||
.register_source(search_source)
|
||||
.await;
|
||||
|
||||
// Extension store
|
||||
search_source_registry_tauri_state
|
||||
.register_source(third_party::install::store::ExtensionStore)
|
||||
.await;
|
||||
|
||||
// Built-in extensions
|
||||
|
||||
// Built-in extension: Application
|
||||
built_in::application::ApplicationSearchSource::prepare_index_and_store(
|
||||
tauri_app_handle.clone(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// extension store
|
||||
search_source_registry_tauri_state
|
||||
.register_source(third_party::install::store::ExtensionStore)
|
||||
.await;
|
||||
|
||||
// Init the built-in enabled extensions
|
||||
for built_in_extension in extensions
|
||||
.extract_if(.., |ext| {
|
||||
built_in::is_extension_built_in(&ext.bundle_id_borrowed())
|
||||
})
|
||||
.filter(|ext| ext.enabled)
|
||||
{
|
||||
let built_in_extensions = built_in::list_built_in_extensions(&tauri_app_handle).await?;
|
||||
for built_in_extension in built_in_extensions.iter().filter(|ext| ext.enabled) {
|
||||
built_in::init_built_in_extension(
|
||||
&tauri_app_handle,
|
||||
&built_in_extension,
|
||||
@@ -694,18 +794,6 @@ pub(crate) async fn init_extensions(
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Now the third-party extensions
|
||||
let third_party_search_source = third_party::ThirdPartyExtensionsSearchSource::new(extensions);
|
||||
third_party_search_source.init(&tauri_app_handle).await?;
|
||||
let third_party_search_source_clone = third_party_search_source.clone();
|
||||
// Set the global search source so that we can access it in `#[tauri::command]`s
|
||||
// ignore the result because this function will be invoked twice, which
|
||||
// means this global variable will be set twice.
|
||||
let _ = THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE.set(third_party_search_source_clone);
|
||||
search_source_registry_tauri_state
|
||||
.register_source(third_party_search_source)
|
||||
.await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -747,7 +835,7 @@ pub(crate) async fn set_extension_alias(
|
||||
let bundle_id_borrowed = bundle_id.borrow();
|
||||
|
||||
if built_in::is_extension_built_in(&bundle_id_borrowed) {
|
||||
built_in::set_built_in_extension_alias(&tauri_app_handle, &bundle_id_borrowed, &alias);
|
||||
built_in::set_built_in_extension_alias(&tauri_app_handle, &bundle_id_borrowed, &alias)?;
|
||||
return Ok(());
|
||||
}
|
||||
third_party::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE.get().expect("global third party search source not set, looks like init_extensions() has not been executed").set_extension_alias(&tauri_app_handle, &bundle_id_borrowed, &alias).await
|
||||
@@ -854,6 +942,55 @@ pub(crate) fn canonicalize_relative_icon_path(
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(views) = &mut extension.views {
|
||||
for view in views {
|
||||
_canonicalize_relative_icon_path(extension_dir, view)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn canonicalize_relative_page_path(
|
||||
extension_dir: &Path,
|
||||
extension: &mut Extension,
|
||||
) -> Result<(), String> {
|
||||
fn _canonicalize_view_extension_page_path(
|
||||
extension_dir: &Path,
|
||||
extension: &mut Extension,
|
||||
) -> Result<(), String> {
|
||||
let page = extension
|
||||
.page
|
||||
.as_ref()
|
||||
.expect("this should be invoked on a View extension");
|
||||
let page_path = Path::new(page);
|
||||
|
||||
if page_path.is_relative() {
|
||||
let absolute_page_path = extension_dir.join(page_path);
|
||||
|
||||
if absolute_page_path.try_exists().map_err(|e| e.to_string())? {
|
||||
extension.page = Some(
|
||||
absolute_page_path
|
||||
.into_os_string()
|
||||
.into_string()
|
||||
.expect("path should be UTF-8 encoded"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
if extension.r#type == ExtensionType::View {
|
||||
_canonicalize_view_extension_page_path(extension_dir, extension)?;
|
||||
} else if extension.r#type.contains_sub_items()
|
||||
&& let Some(ref mut views) = extension.views
|
||||
{
|
||||
for view in views {
|
||||
_canonicalize_view_extension_page_path(extension_dir, view)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -906,6 +1043,14 @@ fn alter_extension_json_file(
|
||||
}
|
||||
}
|
||||
|
||||
// Search in views
|
||||
if let Some(ref mut views) = root_extension.views {
|
||||
if let Some(view) = views.iter_mut().find(|v| v.id == sub_extension_id) {
|
||||
how(view)?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
Err(format!(
|
||||
"extension [{:?}] not found in {:?}",
|
||||
bundle_id, root_extension
|
||||
@@ -1078,6 +1223,126 @@ fn parse_dynamic_placeholder(content: &str) -> Result<QuicklinkLinkComponent, St
|
||||
})
|
||||
}
|
||||
|
||||
/// Built-in extension settings
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub(crate) struct ExtensionSettings {
|
||||
/// If set, Coco main window would hide before opening this document/e
|
||||
pub(crate) hide_before_open: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub(crate) struct ExtensionPermission {
|
||||
fs: Option<Vec<ExtensionFileSystemPermission>>,
|
||||
http: Option<Vec<ExtensionHttpPermission>>,
|
||||
api: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub(crate) struct ExtensionFileSystemPermission {
|
||||
pub(crate) path: String,
|
||||
pub(crate) access: FileSystemAccess,
|
||||
}
|
||||
|
||||
bitflags! {
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub(crate) struct FileSystemAccess: u8 {
|
||||
const READ = 0b00000001;
|
||||
const WRITE = 0b00000010;
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for FileSystemAccess {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
let mut access_vec = Vec::new();
|
||||
if self.contains(FileSystemAccess::READ) {
|
||||
access_vec.push("read");
|
||||
}
|
||||
if self.contains(FileSystemAccess::WRITE) {
|
||||
access_vec.push("write");
|
||||
}
|
||||
access_vec.serialize(serializer)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for FileSystemAccess {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let access_vec: Vec<String> = Vec::deserialize(deserializer)?;
|
||||
let mut access = FileSystemAccess::empty();
|
||||
|
||||
for access_type in access_vec {
|
||||
match access_type.as_str() {
|
||||
"read" => access |= FileSystemAccess::READ,
|
||||
"write" => access |= FileSystemAccess::WRITE,
|
||||
_ => {
|
||||
return Err(serde::de::Error::unknown_variant(
|
||||
access_type.as_str(),
|
||||
&["read", "write"],
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(access)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub(crate) struct ExtensionHttpPermission {
|
||||
pub(crate) host: String,
|
||||
}
|
||||
|
||||
/// Calculates a similarity score between a query and a text, aiming for a [0, 1] range.
|
||||
/// Assumes query and text are already lowercased.
|
||||
///
|
||||
/// Used in extension_to_hit().
|
||||
fn calculate_text_similarity(query: &str, text: &str) -> Option<f64> {
|
||||
if query.is_empty() || text.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if text == query {
|
||||
return Some(1.0); // Perfect match
|
||||
}
|
||||
|
||||
let query_len = query.len() as f64;
|
||||
let text_len = text.len() as f64;
|
||||
let ratio = query_len / text_len;
|
||||
let mut score: f64 = 0.0;
|
||||
|
||||
// Case 1: Text starts with the query (prefix match)
|
||||
// Score: base 0.5, bonus up to 0.4 for how much of `text` is covered by `query`. Max 0.9.
|
||||
if text.starts_with(query) {
|
||||
score = score.max(0.5 + 0.4 * ratio);
|
||||
}
|
||||
|
||||
// Case 2: Text contains the query (substring match, not necessarily prefix)
|
||||
// Score: base 0.3, bonus up to 0.3. Max 0.6.
|
||||
// `score.max` ensures that if it's both a prefix and contains, the higher score (prefix) is taken.
|
||||
if text.contains(query) {
|
||||
score = score.max(0.3 + 0.3 * ratio);
|
||||
}
|
||||
|
||||
// Case 3: Fallback for "all query characters exist in text" (order-independent)
|
||||
if score < 0.2 {
|
||||
if query.chars().all(|c_q| text.contains(c_q)) {
|
||||
score = score.max(0.15); // Fixed low score for this weaker match type
|
||||
}
|
||||
}
|
||||
|
||||
if score > 0.0 {
|
||||
// Cap non-perfect matches slightly below 1.0 to make perfect (1.0) distinct.
|
||||
Some(score.min(0.95))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1548,4 +1813,234 @@ mod tests {
|
||||
let result = link.concatenate_url(&None);
|
||||
assert_eq!(result, "");
|
||||
}
|
||||
|
||||
// Helper function for approximate floating point comparison
|
||||
fn approx_eq(a: f64, b: f64) -> bool {
|
||||
(a - b).abs() < 1e-10
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_strings() {
|
||||
assert_eq!(calculate_text_similarity("", "text"), None);
|
||||
assert_eq!(calculate_text_similarity("query", ""), None);
|
||||
assert_eq!(calculate_text_similarity("", ""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_perfect_match() {
|
||||
assert_eq!(calculate_text_similarity("text", "text"), Some(1.0));
|
||||
assert_eq!(calculate_text_similarity("a", "a"), Some(1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_prefix_match() {
|
||||
// For "te" and "text":
|
||||
// score = 0.5 + 0.4 * (2/4) = 0.5 + 0.2 = 0.7
|
||||
let score = calculate_text_similarity("te", "text").unwrap();
|
||||
assert!(approx_eq(score, 0.7));
|
||||
|
||||
// For "tex" and "text":
|
||||
// score = 0.5 + 0.4 * (3/4) = 0.5 + 0.3 = 0.8
|
||||
let score = calculate_text_similarity("tex", "text").unwrap();
|
||||
assert!(approx_eq(score, 0.8));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_substring_match() {
|
||||
// For "ex" and "text":
|
||||
// score = 0.3 + 0.3 * (2/4) = 0.3 + 0.15 = 0.45
|
||||
let score = calculate_text_similarity("ex", "text").unwrap();
|
||||
assert!(approx_eq(score, 0.45));
|
||||
|
||||
// Prefix should score higher than substring
|
||||
assert!(
|
||||
calculate_text_similarity("te", "text").unwrap()
|
||||
> calculate_text_similarity("ex", "text").unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_character_presence() {
|
||||
// Characters present but not in sequence
|
||||
// "tac" in "contact" - not a substring, but all chars exist
|
||||
let score = calculate_text_similarity("tac", "contact").unwrap();
|
||||
assert!(approx_eq(0.3 + 0.3 * (3.0 / 7.0), score));
|
||||
|
||||
assert!(calculate_text_similarity("ac", "contact").is_some());
|
||||
|
||||
// Should not apply if some characters are missing
|
||||
assert_eq!(calculate_text_similarity("xyz", "contact"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_combined_scenarios() {
|
||||
// Test that character presence fallback doesn't override higher scores
|
||||
// "tex" is a prefix of "text" with score 0.8
|
||||
let score = calculate_text_similarity("tex", "text").unwrap();
|
||||
assert!(approx_eq(score, 0.8));
|
||||
|
||||
// Test a case where the characters exist but it's already a substring
|
||||
// "act" is a substring of "contact" with score > 0.2, so fallback won't apply
|
||||
let expected_score = 0.3 + 0.3 * (3.0 / 7.0);
|
||||
let actual_score = calculate_text_similarity("act", "contact").unwrap();
|
||||
assert!(approx_eq(actual_score, expected_score));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_similarity() {
|
||||
assert_eq!(calculate_text_similarity("xyz", "test"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_score_capping() {
|
||||
// Use a long query that's a prefix of a slightly longer text
|
||||
let long_text = "abcdefghijklmnopqrstuvwxyz";
|
||||
let long_prefix = "abcdefghijklmnopqrstuvwxy"; // All but last letter
|
||||
|
||||
// Expected score would be 0.5 + 0.4 * (25/26) = 0.5 + 0.385 = 0.885
|
||||
let expected_score = 0.5 + 0.4 * (25.0 / 26.0);
|
||||
let actual_score = calculate_text_similarity(long_prefix, long_text).unwrap();
|
||||
assert!(approx_eq(actual_score, expected_score));
|
||||
|
||||
// Verify that non-perfect matches are capped at 0.95
|
||||
assert!(calculate_text_similarity("almost", "almost perfect").unwrap() <= 0.95);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filesystem_access_serialize_empty() {
|
||||
let access = FileSystemAccess::empty();
|
||||
let serialized = serde_json::to_string(&access).unwrap();
|
||||
assert_eq!(serialized, "[]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filesystem_access_serialize_read_only() {
|
||||
let access = FileSystemAccess::READ;
|
||||
let serialized = serde_json::to_string(&access).unwrap();
|
||||
assert_eq!(serialized, r#"["read"]"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filesystem_access_serialize_write_only() {
|
||||
let access = FileSystemAccess::WRITE;
|
||||
let serialized = serde_json::to_string(&access).unwrap();
|
||||
assert_eq!(serialized, r#"["write"]"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filesystem_access_serialize_read_write() {
|
||||
let access = FileSystemAccess::READ | FileSystemAccess::WRITE;
|
||||
let serialized = serde_json::to_string(&access).unwrap();
|
||||
// The order should be consistent based on our implementation (read first, then write)
|
||||
assert_eq!(serialized, r#"["read","write"]"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filesystem_access_deserialize_empty() {
|
||||
let json = "[]";
|
||||
let access: FileSystemAccess = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(access, FileSystemAccess::empty());
|
||||
assert!(!access.contains(FileSystemAccess::READ));
|
||||
assert!(!access.contains(FileSystemAccess::WRITE));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filesystem_access_deserialize_read_only() {
|
||||
let json = r#"["read"]"#;
|
||||
let access: FileSystemAccess = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(access, FileSystemAccess::READ);
|
||||
assert!(access.contains(FileSystemAccess::READ));
|
||||
assert!(!access.contains(FileSystemAccess::WRITE));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filesystem_access_deserialize_write_only() {
|
||||
let json = r#"["write"]"#;
|
||||
let access: FileSystemAccess = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(access, FileSystemAccess::WRITE);
|
||||
assert!(!access.contains(FileSystemAccess::READ));
|
||||
assert!(access.contains(FileSystemAccess::WRITE));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filesystem_access_deserialize_read_write() {
|
||||
let json = r#"["read", "write"]"#;
|
||||
let access: FileSystemAccess = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(access, FileSystemAccess::READ | FileSystemAccess::WRITE);
|
||||
assert!(access.contains(FileSystemAccess::READ));
|
||||
assert!(access.contains(FileSystemAccess::WRITE));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filesystem_access_deserialize_write_read_order() {
|
||||
// Test that order doesn't matter during deserialization
|
||||
let json = r#"["write", "read"]"#;
|
||||
let access: FileSystemAccess = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(access, FileSystemAccess::READ | FileSystemAccess::WRITE);
|
||||
assert!(access.contains(FileSystemAccess::READ));
|
||||
assert!(access.contains(FileSystemAccess::WRITE));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filesystem_access_deserialize_duplicate_values() {
|
||||
// Test that duplicate values don't cause issues
|
||||
let json = r#"["read", "read", "write"]"#;
|
||||
let access: FileSystemAccess = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(access, FileSystemAccess::READ | FileSystemAccess::WRITE);
|
||||
assert!(access.contains(FileSystemAccess::READ));
|
||||
assert!(access.contains(FileSystemAccess::WRITE));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filesystem_access_deserialize_invalid_value() {
|
||||
let json = r#"["invalid"]"#;
|
||||
let result: Result<FileSystemAccess, _> = serde_json::from_str(json);
|
||||
assert!(result.is_err());
|
||||
|
||||
let error_msg = result.unwrap_err().to_string();
|
||||
assert!(error_msg.contains("invalid"));
|
||||
assert!(error_msg.contains("read") && error_msg.contains("write"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filesystem_access_deserialize_mixed_valid_invalid() {
|
||||
let json = r#"["read", "invalid", "write"]"#;
|
||||
let result: Result<FileSystemAccess, _> = serde_json::from_str(json);
|
||||
assert!(result.is_err());
|
||||
|
||||
let error_msg = result.unwrap_err().to_string();
|
||||
assert!(error_msg.contains("invalid"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filesystem_access_round_trip_empty() {
|
||||
let original = FileSystemAccess::empty();
|
||||
let serialized = serde_json::to_string(&original).unwrap();
|
||||
let deserialized: FileSystemAccess = serde_json::from_str(&serialized).unwrap();
|
||||
assert_eq!(original, deserialized);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filesystem_access_round_trip_read() {
|
||||
let original = FileSystemAccess::READ;
|
||||
let serialized = serde_json::to_string(&original).unwrap();
|
||||
let deserialized: FileSystemAccess = serde_json::from_str(&serialized).unwrap();
|
||||
assert_eq!(original, deserialized);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filesystem_access_round_trip_write() {
|
||||
let original = FileSystemAccess::WRITE;
|
||||
let serialized = serde_json::to_string(&original).unwrap();
|
||||
let deserialized: FileSystemAccess = serde_json::from_str(&serialized).unwrap();
|
||||
assert_eq!(original, deserialized);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filesystem_access_round_trip_read_write() {
|
||||
let original = FileSystemAccess::READ | FileSystemAccess::WRITE;
|
||||
let serialized = serde_json::to_string(&original).unwrap();
|
||||
let deserialized: FileSystemAccess = serde_json::from_str(&serialized).unwrap();
|
||||
assert_eq!(original, deserialized);
|
||||
}
|
||||
}
|
||||
|
||||
113
src-tauri/src/extension/third_party/check.rs
vendored
113
src-tauri/src/extension/third_party/check.rs
vendored
@@ -47,7 +47,11 @@ pub(crate) fn general_check(extension: &Extension) -> Result<(), String> {
|
||||
Some(ref v) => v.as_slice(),
|
||||
None => &[],
|
||||
};
|
||||
let sub_extensions = [commands, scripts, quicklinks].concat();
|
||||
let views = match extension.views {
|
||||
Some(ref v) => v.as_slice(),
|
||||
None => &[],
|
||||
};
|
||||
let sub_extensions = [commands, scripts, quicklinks, views].concat();
|
||||
let mut sub_extension_ids = HashSet::new();
|
||||
|
||||
for sub_extension in sub_extensions.iter() {
|
||||
@@ -93,7 +97,10 @@ fn check_main_extension_only(extension: &Extension) -> Result<(), String> {
|
||||
}
|
||||
}
|
||||
|
||||
if extension.commands.is_some() || extension.scripts.is_some() || extension.quicklinks.is_some()
|
||||
if extension.commands.is_some()
|
||||
|| extension.scripts.is_some()
|
||||
|| extension.quicklinks.is_some()
|
||||
|| extension.views.is_some()
|
||||
{
|
||||
if extension.r#type != ExtensionType::Group && extension.r#type != ExtensionType::Extension
|
||||
{
|
||||
@@ -104,6 +111,16 @@ fn check_main_extension_only(extension: &Extension) -> Result<(), String> {
|
||||
}
|
||||
}
|
||||
|
||||
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(())
|
||||
}
|
||||
|
||||
@@ -124,9 +141,10 @@ fn check_sub_extension_only(
|
||||
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] should not be set in sub-extensions",
|
||||
"invalid sub-extension [{}-{}]: fields [commands/scripts/quicklinks/views] should not be set in sub-extensions",
|
||||
extension_id, sub_extension.id
|
||||
));
|
||||
}
|
||||
@@ -198,16 +216,39 @@ fn check_main_extension_or_sub_extension(
|
||||
));
|
||||
}
|
||||
|
||||
// If field `page` is Some, then it should be a View
|
||||
if extension.page.is_some() && extension.r#type != ExtensionType::View {
|
||||
return Err(format!(
|
||||
"invalid {}, field [page] is set for a non-View extension",
|
||||
identifier
|
||||
));
|
||||
}
|
||||
|
||||
if extension.r#type == ExtensionType::View && extension.page.is_none() {
|
||||
return Err(format!(
|
||||
"invalid {}, field [page] should be set for a View extension",
|
||||
identifier
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::extension::{CommandAction, Quicklink, QuicklinkLink, QuicklinkLinkComponent};
|
||||
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(),
|
||||
@@ -221,9 +262,12 @@ mod tests {
|
||||
commands: None,
|
||||
scripts: None,
|
||||
quicklinks: None,
|
||||
views: None,
|
||||
alias: None,
|
||||
hotkey: None,
|
||||
enabled: true,
|
||||
page,
|
||||
permission: None,
|
||||
settings: None,
|
||||
screenshots: None,
|
||||
url: None,
|
||||
@@ -309,6 +353,29 @@ mod tests {
|
||||
.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 */
|
||||
@@ -366,6 +433,36 @@ mod tests {
|
||||
.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 */
|
||||
@@ -431,11 +528,9 @@ mod tests {
|
||||
|
||||
let result = general_check(&extension);
|
||||
assert!(result.is_err());
|
||||
assert!(
|
||||
result.unwrap_err().contains(
|
||||
"fields [commands/scripts/quicklinks] should not be set in sub-extensions"
|
||||
)
|
||||
);
|
||||
assert!(result.unwrap_err().contains(
|
||||
"fields [commands/scripts/quicklinks/views] should not be set in sub-extensions"
|
||||
));
|
||||
}
|
||||
/* Test check_sub_extension_only */
|
||||
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
use crate::extension::PLUGIN_JSON_FILE_NAME;
|
||||
use crate::extension::third_party::check::general_check;
|
||||
use crate::extension::third_party::install::{
|
||||
filter_out_incompatible_sub_extensions, is_extension_installed,
|
||||
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};
|
||||
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;
|
||||
@@ -165,6 +167,14 @@ pub(crate) async fn install_local_extension(
|
||||
// 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)
|
||||
@@ -211,15 +221,59 @@ pub(crate) async fn install_local_extension(
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Canonicalize relative icon paths
|
||||
/*
|
||||
* Call convert_page() to update the page files. This has to be done after
|
||||
* writing the extension files
|
||||
*/
|
||||
let absolute_page_paths: Vec<PathBuf> = {
|
||||
fn canonicalize_page_path(page_path: &Path, extension_root: &Path) -> PathBuf {
|
||||
if page_path.is_relative() {
|
||||
// It is relative to the extension root directory
|
||||
extension_root.join(page_path)
|
||||
} else {
|
||||
page_path.into()
|
||||
}
|
||||
}
|
||||
|
||||
if extension.r#type == ExtensionType::View {
|
||||
let page = extension
|
||||
.page
|
||||
.as_ref()
|
||||
.expect("View extension should set its page field");
|
||||
let path = canonicalize_page_path(Path::new(page.as_str()), &dest_dir);
|
||||
|
||||
vec![path]
|
||||
} else if extension.r#type.contains_sub_items()
|
||||
&& let Some(ref views) = extension.views
|
||||
{
|
||||
let mut paths = Vec::with_capacity(views.len());
|
||||
|
||||
for view in views.iter() {
|
||||
let page = view
|
||||
.page
|
||||
.as_ref()
|
||||
.expect("View extension should set its page field");
|
||||
let path = canonicalize_page_path(Path::new(page.as_str()), &dest_dir);
|
||||
|
||||
paths.push(path);
|
||||
}
|
||||
|
||||
paths
|
||||
} else {
|
||||
// No pages in this extension
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
for page_path in absolute_page_paths {
|
||||
convert_page(&page_path).await?;
|
||||
}
|
||||
|
||||
// Canonicalize relative icon and page paths
|
||||
canonicalize_relative_icon_path(&dest_dir, &mut extension)?;
|
||||
canonicalize_relative_page_path(&dest_dir, &mut extension)?;
|
||||
|
||||
// Add extension to the search source
|
||||
THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE
|
||||
.get()
|
||||
.unwrap()
|
||||
.add_extension(extension)
|
||||
.await;
|
||||
third_party_ext_list_write_lock.push(extension);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
496
src-tauri/src/extension/third_party/install/mod.rs
vendored
496
src-tauri/src/extension/third_party/install/mod.rs
vendored
@@ -3,38 +3,54 @@
|
||||
//!
|
||||
//! # How
|
||||
//!
|
||||
//! Technically, installing an extension involves the following steps:
|
||||
//!
|
||||
//! 1. Correct the `plugin.json` JSON if it does not conform to our `struct Extension`
|
||||
//! definition.
|
||||
//! Technically, installing an extension involves the following steps. The order
|
||||
//! may vary between implementations.
|
||||
//!
|
||||
//! 2. Write the extension files to the corresponding location
|
||||
//! 1. Check if it is already installed, if so, return
|
||||
//!
|
||||
//! 2. Correct the `plugin.json` JSON if it does not conform to our `struct
|
||||
//! Extension` definition. This can happen because the JSON written by
|
||||
//! developers is in a simplified form for a better developer experience.
|
||||
//!
|
||||
//! 3. Validate the corrected `plugin.json`
|
||||
//! 1. misc checks
|
||||
//! 2. Platform compatibility check
|
||||
//!
|
||||
//! 4. Write the extension files to the corresponding location
|
||||
//!
|
||||
//! * developer directory
|
||||
//! * extension directory
|
||||
//! * assets directory
|
||||
//! * various assets files, e.g., "icon.png"
|
||||
//! * plugin.json file
|
||||
//! * View pages if exist
|
||||
//!
|
||||
//! 3. Canonicalize the `Extension.icon` fields if they are relative paths
|
||||
//! (relative to the `assets` directory)
|
||||
//! 5. If this extension contains any View extensions, call `convert_page()`
|
||||
//! on them to make them loadable by Tauri/webview.
|
||||
//!
|
||||
//! 4. Deserialize the `plugin.json` file to a `struct Extension`, and call
|
||||
//! `THIRD_PARTY_EXTENSIONS_DIRECTORY.add_extension(extension)` to add it to
|
||||
//! the in-memory extension list.
|
||||
//! 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()
|
||||
.unwrap()
|
||||
.expect("global third party search source not set")
|
||||
.extension_exists(developer, extension_id)
|
||||
.await
|
||||
}
|
||||
@@ -51,14 +67,16 @@ pub(crate) fn filter_out_incompatible_sub_extensions(
|
||||
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 platforms is None, the sub-extension is compatible with all platforms
|
||||
if let Some(ref platforms) = sub_ext.platforms {
|
||||
platforms.contains(¤t_platform)
|
||||
} else {
|
||||
true
|
||||
main_extension_supported_platforms.contains(¤t_platform)
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -66,11 +84,10 @@ pub(crate) fn filter_out_incompatible_sub_extensions(
|
||||
// Filter scripts
|
||||
if let Some(ref mut scripts) = extension.scripts {
|
||||
scripts.retain(|sub_ext| {
|
||||
// If platforms is None, the sub-extension is compatible with all platforms
|
||||
if let Some(ref platforms) = sub_ext.platforms {
|
||||
platforms.contains(¤t_platform)
|
||||
} else {
|
||||
true
|
||||
main_extension_supported_platforms.contains(¤t_platform)
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -78,14 +95,137 @@ pub(crate) fn filter_out_incompatible_sub_extensions(
|
||||
// Filter quicklinks
|
||||
if let Some(ref mut quicklinks) = extension.quicklinks {
|
||||
quicklinks.retain(|sub_ext| {
|
||||
// If platforms is None, the sub-extension is compatible with all platforms
|
||||
if let Some(ref platforms) = sub_ext.platforms {
|
||||
platforms.contains(¤t_platform)
|
||||
} else {
|
||||
true
|
||||
main_extension_supported_platforms.contains(¤t_platform)
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Filter views
|
||||
if let Some(ref mut views) = extension.views {
|
||||
views.retain(|sub_ext| {
|
||||
if let Some(ref platforms) = sub_ext.platforms {
|
||||
platforms.contains(¤t_platform)
|
||||
} else {
|
||||
main_extension_supported_platforms.contains(¤t_platform)
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert the page file to make it loadable by the Tauri/Webview.
|
||||
pub(crate) async fn convert_page(absolute_page_path: &Path) -> Result<(), String> {
|
||||
assert!(absolute_page_path.is_absolute());
|
||||
|
||||
let page_content = tokio::fs::read_to_string(absolute_page_path)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let new_page_content = _convert_page(&page_content, absolute_page_path)?;
|
||||
|
||||
// overwrite it
|
||||
tokio::fs::write(absolute_page_path, new_page_content)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// NOTE: There is no Rust implementation of `convertFileSrc()` in Tauri. Our
|
||||
/// impl here is based on [comment](https://github.com/tauri-apps/tauri/issues/12022#issuecomment-2572879115)
|
||||
fn convert_file_src(path: &Path) -> Result<String, String> {
|
||||
#[cfg(any(windows, target_os = "android"))]
|
||||
let base = "http://asset.localhost/";
|
||||
#[cfg(not(any(windows, target_os = "android")))]
|
||||
let base = "asset://localhost/";
|
||||
|
||||
let path =
|
||||
dunce::canonicalize(path).map_err(|e| format!("Failed to canonicalize path: {}", e))?;
|
||||
let path_str = path.to_string_lossy();
|
||||
let encoded = urlencoding::encode(&path_str);
|
||||
|
||||
Ok(format!("{base}{encoded}"))
|
||||
}
|
||||
|
||||
/// Tauri cannot directly access the file system, to make a file loadable, we
|
||||
/// have to `canonicalize()` and `convertFileSrc()` its path before passing it
|
||||
/// to Tauri.
|
||||
///
|
||||
/// View extension's page is a HTML file that Coco (Tauri) will load, we need
|
||||
/// to process all `<PATH>` tags:
|
||||
///
|
||||
/// 1. `<script type="xxx" crossorigin src="<PATH>"></script>`
|
||||
/// 2. `<a href="<PATH>">xxx</a>`
|
||||
/// 3. `<link rel="xxx" href="<PATH>"/>`
|
||||
/// 4. `<img class="xxx" src="<PATH>" alt="xxx"/>`
|
||||
fn _convert_page(page_content: &str, absolute_page_path: &Path) -> Result<String, String> {
|
||||
use scraper::{Html, Selector};
|
||||
|
||||
/// Helper function.
|
||||
///
|
||||
/// Search `document` for the tag attributes specified by `tag_with_attribute`
|
||||
/// and `tag_attribute`, call `convert_file_src()`, then update the attribute
|
||||
/// value with the function return value.
|
||||
fn modify_tag_attributes(
|
||||
document: &Html,
|
||||
modified_html: &mut String,
|
||||
base_dir: &Path,
|
||||
tag_with_attribute: &str,
|
||||
tag_attribute: &str,
|
||||
) -> Result<(), String> {
|
||||
let script_selector = Selector::parse(tag_with_attribute).unwrap();
|
||||
for element in document.select(&script_selector) {
|
||||
if let Some(src) = element.value().attr(tag_attribute) {
|
||||
if !src.starts_with("http://")
|
||||
&& !src.starts_with("https://")
|
||||
&& !src.starts_with("asset://")
|
||||
&& !src.starts_with("http://asset.localhost/")
|
||||
{
|
||||
// It could be a path like "/assets/index-41be3ec9.js", but it
|
||||
// is still a relative path. We need to remove the starting /
|
||||
// or path.join() will think it is an absolute path and does nothing
|
||||
let corrected_src = if src.starts_with('/') { &src[1..] } else { src };
|
||||
|
||||
let full_path = base_dir.join(corrected_src);
|
||||
|
||||
let converted_path = convert_file_src(full_path.as_path())?;
|
||||
*modified_html = modified_html.replace(
|
||||
&format!("{}=\"{}\"", tag_attribute, src),
|
||||
&format!("{}=\"{}\"", tag_attribute, converted_path),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
let base_dir = absolute_page_path
|
||||
.parent()
|
||||
.ok_or_else(|| format!("page path is invalid, it should have a parent path"))?;
|
||||
let document: Html = Html::parse_document(page_content);
|
||||
let mut modified_html: String = page_content.to_string();
|
||||
|
||||
modify_tag_attributes(
|
||||
&document,
|
||||
&mut modified_html,
|
||||
base_dir,
|
||||
"script[src]",
|
||||
"src",
|
||||
)?;
|
||||
modify_tag_attributes(&document, &mut modified_html, base_dir, "a[href]", "href")?;
|
||||
modify_tag_attributes(
|
||||
&document,
|
||||
&mut modified_html,
|
||||
base_dir,
|
||||
"link[href]",
|
||||
"href",
|
||||
)?;
|
||||
modify_tag_attributes(&document, &mut modified_html, base_dir, "img[src]", "src")?;
|
||||
|
||||
Ok(modified_html)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -113,10 +253,13 @@ mod tests {
|
||||
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,
|
||||
@@ -154,10 +297,15 @@ mod tests {
|
||||
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);
|
||||
@@ -166,6 +314,7 @@ mod tests {
|
||||
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.
|
||||
@@ -186,10 +335,15 @@ mod tests {
|
||||
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);
|
||||
@@ -198,19 +352,23 @@ mod tests {
|
||||
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);
|
||||
}
|
||||
|
||||
// `platforms: None` means all platforms as well
|
||||
// main extension is compatible with all platforms, sub extension's platforms
|
||||
// is None, which means all platforms are supported
|
||||
{
|
||||
let mut main_extension = create_test_extension(ExtensionType::Group, None);
|
||||
// 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);
|
||||
@@ -219,6 +377,308 @@ mod tests {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
135
src-tauri/src/extension/third_party/install/store.rs
vendored
135
src-tauri/src/extension/third_party/install/store.rs
vendored
@@ -10,11 +10,14 @@ 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;
|
||||
@@ -23,6 +26,8 @@ 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";
|
||||
@@ -172,6 +177,53 @@ pub(crate) async fn search_extension(
|
||||
Ok(extensions)
|
||||
}
|
||||
|
||||
#[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]
|
||||
pub(crate) async fn install_extension_from_store(
|
||||
tauri_app_handle: AppHandle,
|
||||
@@ -250,21 +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, Platform::current());
|
||||
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(())
|
||||
}
|
||||
|
||||
314
src-tauri/src/extension/third_party/mod.rs
vendored
314
src-tauri/src/extension/third_party/mod.rs
vendored
@@ -15,11 +15,14 @@ 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::io::ErrorKind;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
@@ -42,7 +45,7 @@ pub(crate) fn get_third_party_extension_directory(tauri_app_handle: &AppHandle)
|
||||
app_data_dir
|
||||
}
|
||||
|
||||
pub(crate) async fn list_third_party_extensions(
|
||||
pub(crate) async fn load_third_party_extensions_from_directory(
|
||||
directory: &Path,
|
||||
) -> Result<Vec<Extension>, String> {
|
||||
let mut extensions_dir_iter = read_dir(&directory).await.map_err(|e| e.to_string())?;
|
||||
@@ -181,6 +184,7 @@ pub(crate) async fn list_third_party_extensions(
|
||||
|
||||
// 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);
|
||||
}
|
||||
@@ -202,7 +206,7 @@ pub(crate) async fn list_third_party_extensions(
|
||||
/// 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>,
|
||||
}
|
||||
|
||||
@@ -288,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(())
|
||||
@@ -330,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(())
|
||||
@@ -343,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,
|
||||
@@ -457,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) {
|
||||
@@ -628,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)]
|
||||
@@ -749,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())
|
||||
@@ -777,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>,
|
||||
@@ -846,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,14 +10,13 @@ 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, WebviewWindow, WindowEvent};
|
||||
use tauri_plugin_autostart::MacosLauncher;
|
||||
@@ -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")]
|
||||
@@ -164,30 +162,25 @@ pub fn run() {
|
||||
extension::unregister_extension_hotkey,
|
||||
extension::is_extension_enabled,
|
||||
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,
|
||||
#[cfg(target_os = "macos")]
|
||||
setup::toggle_move_to_active_space_attribute,
|
||||
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,67 +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
|
||||
|
||||
// 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(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(())
|
||||
})
|
||||
@@ -320,6 +267,16 @@ async fn show_coco(app_handle: AppHandle) {
|
||||
|
||||
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", ());
|
||||
|
||||
@@ -9,7 +9,7 @@ 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]
|
||||
|
||||
@@ -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,14 +1,9 @@
|
||||
//! credits to: https://github.com/ayangweb/ayangweb-EcoPaste/blob/169323dbe6365ffe4abb64d867439ed2ea84c6d1/src-tauri/src/core/setup/mac.rs
|
||||
|
||||
use cocoa::appkit::NSWindow;
|
||||
use tauri::Manager;
|
||||
use tauri::{App, AppHandle, Emitter, EventTarget, WebviewWindow};
|
||||
use tauri_nspanel::{WebviewWindowExt, cocoa::appkit::NSWindowCollectionBehavior, panel_delegate};
|
||||
|
||||
use crate::common::MAIN_WINDOW_LABEL;
|
||||
|
||||
#[allow(non_upper_case_globals)]
|
||||
const NSWindowStyleMaskNonActivatingPanel: i32 = 1 << 7;
|
||||
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";
|
||||
@@ -16,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,
|
||||
@@ -24,13 +19,19 @@ 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::NSWindowCollectionBehaviorMoveToActiveSpace
|
||||
| NSWindowCollectionBehavior::NSWindowCollectionBehaviorStationary
|
||||
@@ -81,50 +82,3 @@ pub fn platform(
|
||||
// Set the delegate object for the window to handle window events
|
||||
panel.set_delegate(delegate);
|
||||
}
|
||||
|
||||
/// Change NS window attribute between `NSWindowCollectionBehaviorCanJoinAllSpaces`
|
||||
/// and `NSWindowCollectionBehaviorMoveToActiveSpace` accordingly.
|
||||
///
|
||||
/// NOTE: this tauri command is not async because we should run it in the main
|
||||
/// thread, or `ns_window.setCollectionBehavior_(collection_behavior)` would lead
|
||||
/// to UB.
|
||||
#[tauri::command]
|
||||
pub(crate) fn toggle_move_to_active_space_attribute(tauri_app_hanlde: AppHandle) {
|
||||
use cocoa::appkit::NSWindowCollectionBehavior;
|
||||
use cocoa::base::id;
|
||||
|
||||
let main_window = tauri_app_hanlde
|
||||
.get_webview_window(MAIN_WINDOW_LABEL)
|
||||
.unwrap();
|
||||
let ns_window = main_window.ns_window().unwrap() as id;
|
||||
let mut collection_behavior = unsafe { ns_window.collectionBehavior() };
|
||||
let join_all_spaces = collection_behavior
|
||||
.contains(NSWindowCollectionBehavior::NSWindowCollectionBehaviorCanJoinAllSpaces);
|
||||
let move_to_active_space = collection_behavior
|
||||
.contains(NSWindowCollectionBehavior::NSWindowCollectionBehaviorMoveToActiveSpace);
|
||||
|
||||
match (join_all_spaces, move_to_active_space) {
|
||||
(true, false) => {
|
||||
collection_behavior
|
||||
.remove(NSWindowCollectionBehavior::NSWindowCollectionBehaviorCanJoinAllSpaces);
|
||||
collection_behavior
|
||||
.insert(NSWindowCollectionBehavior::NSWindowCollectionBehaviorMoveToActiveSpace);
|
||||
}
|
||||
(false, true) => {
|
||||
collection_behavior
|
||||
.remove(NSWindowCollectionBehavior::NSWindowCollectionBehaviorMoveToActiveSpace);
|
||||
collection_behavior
|
||||
.insert(NSWindowCollectionBehavior::NSWindowCollectionBehaviorCanJoinAllSpaces);
|
||||
}
|
||||
_ => {
|
||||
panic!(
|
||||
"invalid NS window attribute, NSWindowCollectionBehaviorCanJoinAllSpaces is set [{}], NSWindowCollectionBehaviorMoveToActiveSpace is set [{}]",
|
||||
join_all_spaces, move_to_active_space
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
unsafe {
|
||||
ns_window.setCollectionBehavior_(collection_behavior);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, 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");
|
||||
}
|
||||
@@ -118,12 +119,9 @@ fn _register_shortcut(app: &AppHandle, 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,7 +145,10 @@ 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.
|
||||
|
||||
@@ -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,6 +1,9 @@
|
||||
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};
|
||||
@@ -11,7 +14,7 @@ use tauri_plugin_shell::ShellExt;
|
||||
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);
|
||||
|
||||
12
src-tauri/src/util/path.rs
Normal file
12
src-tauri/src/util/path.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
#[tauri::command]
|
||||
pub(crate) fn path_absolute(path: &str) -> String {
|
||||
// We do not use std::path::absolute() because it does not clean ".."
|
||||
// https://doc.rust-lang.org/stable/std/path/fn.absolute.html#platform-specific-behavior
|
||||
use path_clean::clean;
|
||||
|
||||
let clean_path = clean(path);
|
||||
clean_path
|
||||
.into_os_string()
|
||||
.into_string()
|
||||
.expect("path should be UTF-8 encoded")
|
||||
}
|
||||
@@ -54,7 +54,6 @@ impl Platform {
|
||||
}
|
||||
|
||||
/// Returns a set that contains all the platforms.
|
||||
#[cfg(test)] // currently, only used in tests
|
||||
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('-', "_")
|
||||
}
|
||||
@@ -129,7 +129,6 @@
|
||||
"shell": {},
|
||||
"globalShortcut": {},
|
||||
"deep-link": {
|
||||
"schema": "coco",
|
||||
"mobile": [
|
||||
{
|
||||
"host": "app.infini.cloud",
|
||||
|
||||
@@ -86,11 +86,6 @@ export const Get = <T>(
|
||||
} else {
|
||||
res = result?.data as FcResponse<T>;
|
||||
}
|
||||
// web component log
|
||||
infoLog({
|
||||
username: "@/api/axiosRequest.ts",
|
||||
logName: url,
|
||||
})(res);
|
||||
|
||||
resolve([null, res as FcResponse<T>]);
|
||||
})
|
||||
|
||||
@@ -15,50 +15,10 @@ import {
|
||||
MultiSourceQueryResponse,
|
||||
} from "@/types/commands";
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
import { useAuthStore } from "@/stores/authStore";
|
||||
import { useConnectStore } from "@/stores/connectStore";
|
||||
import { SETTINGS_WINDOW_LABEL } from "@/constants";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
|
||||
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) {
|
||||
const windowLabel = await platformAdapter.getCurrentWindowLabel();
|
||||
const { setCurrentService, setCloudSelectService } =
|
||||
useConnectStore.getState();
|
||||
|
||||
return windowLabel === SETTINGS_WINDOW_LABEL
|
||||
? setCloudSelectService(service)
|
||||
: 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 });
|
||||
}
|
||||
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 = [
|
||||
@@ -78,16 +38,9 @@ async function invokeWithErrorHandler<T>(
|
||||
command: string,
|
||||
args?: Record<string, any>
|
||||
): Promise<T> {
|
||||
const isCurrentLogin = useAuthStore.getState().isCurrentLogin;
|
||||
|
||||
const service = await getCurrentWindowService();
|
||||
|
||||
// Not logged in
|
||||
// console.log("isCurrentLogin", command, isCurrentLogin);
|
||||
if (
|
||||
!WHITELIST_SERVERS.includes(command) &&
|
||||
(!isCurrentLogin || !service?.profile)
|
||||
) {
|
||||
if (!WHITELIST_SERVERS.includes(command) && !service?.profile) {
|
||||
console.error("This command requires authentication");
|
||||
throw new Error("This command requires authentication");
|
||||
}
|
||||
|
||||
@@ -34,8 +34,4 @@ export function show_check(): Promise<void> {
|
||||
|
||||
export function hide_check(): Promise<void> {
|
||||
return invoke('hide_check');
|
||||
}
|
||||
|
||||
export function toggle_move_to_active_space_attribute(): Promise<void> {
|
||||
return invoke('toggle_move_to_active_space_attribute');
|
||||
}
|
||||
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: [],
|
||||
|
||||
@@ -233,7 +233,7 @@ const ChatAI = memo(
|
||||
async (params: SendMessageParams) => {
|
||||
try {
|
||||
//console.log("init", curChatEnd, activeChat?._id);
|
||||
if (!isCurrentLogin) {
|
||||
if (isTauri && !isCurrentLogin) {
|
||||
addError("Please login to continue chatting");
|
||||
return;
|
||||
}
|
||||
@@ -390,7 +390,7 @@ const ChatAI = memo(
|
||||
assistantIDs={assistantIDs}
|
||||
/>
|
||||
|
||||
{isCurrentLogin ? (
|
||||
{!isTauri || (isTauri && isCurrentLogin) ? (
|
||||
<>
|
||||
<ChatContent
|
||||
activeChat={activeChat}
|
||||
|
||||
@@ -12,7 +12,6 @@ import { useShortcutsStore } from "@/stores/shortcutsStore";
|
||||
import { HISTORY_PANEL_ID } from "@/constants";
|
||||
import { AssistantList } from "./AssistantList";
|
||||
import { ServerList } from "./ServerList";
|
||||
import { useTogglePin } from "@/hooks/useTogglePin";
|
||||
|
||||
interface ChatHeaderProps {
|
||||
clearChat: () => void;
|
||||
@@ -35,12 +34,22 @@ export function ChatHeader({
|
||||
showChatHistory = true,
|
||||
assistantIDs,
|
||||
}: ChatHeaderProps) {
|
||||
const { isTauri } = useAppStore();
|
||||
const { isPinned, togglePin } = useTogglePin();
|
||||
const { isPinned, setIsPinned, isTauri } = useAppStore();
|
||||
|
||||
const { historicalRecords, newSession, fixedWindow, external } =
|
||||
useShortcutsStore();
|
||||
|
||||
const togglePin = async () => {
|
||||
try {
|
||||
const { isPinned } = useAppStore.getState();
|
||||
|
||||
setIsPinned(!isPinned);
|
||||
} catch (err) {
|
||||
console.error("Failed to toggle window pin state:", err);
|
||||
setIsPinned(isPinned);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<header
|
||||
className="flex items-center justify-between py-2 px-3 select-none"
|
||||
|
||||
@@ -18,7 +18,7 @@ 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";
|
||||
import { getCurrentWindowService, setCurrentWindowService } from "@/commands/windowService";
|
||||
|
||||
interface ServerListProps {
|
||||
clearChat: () => void;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -67,7 +67,7 @@ export const MessageActions = ({
|
||||
};
|
||||
|
||||
const handleSpeak = async () => {
|
||||
if (isDefaultServer()) {
|
||||
if (await isDefaultServer()) {
|
||||
return setSynthesizeItem({ id, content });
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useEffect, useRef, useState, useCallback } from "react";
|
||||
import { isEqual } from "lodash-es";
|
||||
import { usePrevious } from "ahooks";
|
||||
|
||||
import { DataSourcesList } from "./DataSourcesList";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
@@ -7,9 +9,9 @@ import { useAppStore } from "@/stores/appStore";
|
||||
import { useConnectStore } from "@/stores/connectStore";
|
||||
import ServiceInfo from "./ServiceInfo";
|
||||
import ServiceAuth from "./ServiceAuth";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
import type { Server } from "@/types/server";
|
||||
import { useServers } from "@/hooks/useServers";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
|
||||
export default function Cloud() {
|
||||
const SidebarRef = useRef<{ refreshData: () => void }>(null);
|
||||
@@ -24,6 +26,7 @@ export default function Cloud() {
|
||||
serverList,
|
||||
setServerList,
|
||||
} = useConnectStore();
|
||||
const prevServerList = usePrevious(serverList);
|
||||
|
||||
const [refreshLoading, setRefreshLoading] = useState(false);
|
||||
|
||||
@@ -31,6 +34,8 @@ export default function Cloud() {
|
||||
|
||||
// fetch the servers
|
||||
useEffect(() => {
|
||||
if (isEqual(prevServerList, serverList)) return;
|
||||
|
||||
fetchServers();
|
||||
}, [serverList]);
|
||||
|
||||
@@ -40,39 +45,45 @@ export default function Cloud() {
|
||||
}, [cloudSelectService?.id]);
|
||||
|
||||
const fetchServers = useCallback(async () => {
|
||||
let res = serverList;
|
||||
let { serverList } = useConnectStore.getState();
|
||||
|
||||
if (errors.length > 0) {
|
||||
res = res.map((item: Server) => {
|
||||
serverList = serverList.map((item: Server) => {
|
||||
if (item.id === cloudSelectService?.id) {
|
||||
item.health = {
|
||||
services: item.health?.services || {},
|
||||
status: item.health?.status || "red",
|
||||
return {
|
||||
...item,
|
||||
health: {
|
||||
services: item.health?.services || {},
|
||||
status: item.health?.status || "red",
|
||||
},
|
||||
};
|
||||
}
|
||||
return item;
|
||||
});
|
||||
}
|
||||
setServerList(res);
|
||||
|
||||
if (res.length > 0) {
|
||||
const matched = res.find((server: any) => {
|
||||
setServerList(serverList);
|
||||
|
||||
if (serverList.length > 0) {
|
||||
const matched = serverList.find((server: any) => {
|
||||
return server.id === cloudSelectService?.id;
|
||||
});
|
||||
|
||||
if (matched) {
|
||||
setCloudSelectService(matched);
|
||||
} else {
|
||||
setCloudSelectService(res[res.length - 1]);
|
||||
setCloudSelectService(serverList[serverList.length - 1]);
|
||||
}
|
||||
}
|
||||
}, [serverList, errors, cloudSelectService]);
|
||||
}, [errors, cloudSelectService]);
|
||||
|
||||
const refreshClick = useCallback(
|
||||
async (id: string) => {
|
||||
async (id: string, callback?: () => void) => {
|
||||
setRefreshLoading(true);
|
||||
await platformAdapter.commands("refresh_coco_server_info", id);
|
||||
await refreshServerList();
|
||||
setRefreshLoading(false);
|
||||
callback && callback();
|
||||
},
|
||||
[refreshServerList]
|
||||
);
|
||||
|
||||
@@ -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";
|
||||
@@ -18,19 +13,21 @@ 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 cloudSelectService = useConnectStore(
|
||||
(state) => state.cloudSelectService
|
||||
);
|
||||
|
||||
const { logoutServer } = useServers();
|
||||
|
||||
@@ -45,7 +42,7 @@ const ServiceAuth = memo(
|
||||
// Generate the login URL with the current appUid
|
||||
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);
|
||||
@@ -64,100 +61,25 @@ const ServiceAuth = memo(
|
||||
[logoutServer]
|
||||
);
|
||||
|
||||
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]
|
||||
);
|
||||
|
||||
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 = cloudSelectService?.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);
|
||||
@@ -214,7 +136,9 @@ const ServiceAuth = memo(
|
||||
<button
|
||||
className="text-xs text-[#0096FB] dark:text-blue-400 block"
|
||||
onClick={() =>
|
||||
OpenURLWithBrowser(cloudSelectService?.provider?.privacy_policy)
|
||||
OpenURLWithBrowser(
|
||||
cloudSelectService?.provider?.privacy_policy
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("cloud.privacyPolicy")}
|
||||
|
||||
@@ -19,7 +19,6 @@ import source_default_dark_img from "@/assets/images/source_default_dark.png";
|
||||
import { useThemeStore } from "@/stores/themeStore";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
import FontIcon from "../Icons/FontIcon";
|
||||
import { useTogglePin } from "@/hooks/useTogglePin";
|
||||
|
||||
interface FooterProps {
|
||||
setIsPinnedWeb?: (value: boolean) => void;
|
||||
@@ -38,16 +37,29 @@ export default function Footer({ setIsPinnedWeb }: FooterProps) {
|
||||
|
||||
const isDark = useThemeStore((state) => state.isDark);
|
||||
|
||||
const { isTauri } = useAppStore();
|
||||
|
||||
const { isPinned, togglePin } = useTogglePin({
|
||||
onPinChange: setIsPinnedWeb,
|
||||
});
|
||||
const { isTauri, isPinned, setIsPinned } = useAppStore();
|
||||
|
||||
const { setVisible, updateInfo, skipVersions } = useUpdateStore();
|
||||
|
||||
const { fixedWindow, modifierKey } = useShortcutsStore();
|
||||
|
||||
const togglePin = async () => {
|
||||
try {
|
||||
const { isTauri, isPinned } = useAppStore.getState();
|
||||
|
||||
const nextPinned = !isPinned;
|
||||
|
||||
if (!isTauri) {
|
||||
setIsPinnedWeb?.(nextPinned);
|
||||
}
|
||||
|
||||
setIsPinned(nextPinned);
|
||||
} catch (err) {
|
||||
console.error("Failed to toggle window pin state:", err);
|
||||
setIsPinned(isPinned);
|
||||
}
|
||||
};
|
||||
|
||||
const openSetting = useCallback(() => {
|
||||
return platformAdapter.emitEvent("open_settings", "");
|
||||
}, []);
|
||||
|
||||
@@ -111,7 +111,7 @@ const ContextMenu = ({ formatUrl }: ContextMenuProps) => {
|
||||
return [];
|
||||
}
|
||||
|
||||
const { id, url, category, type, payload } = selectedSearchContent;
|
||||
const { id, url, category, type, payload, source } = selectedSearchContent;
|
||||
const { query, result } = payload ?? {};
|
||||
|
||||
if (category === "AI Overview") {
|
||||
@@ -177,6 +177,18 @@ const ContextMenu = ({ formatUrl }: ContextMenuProps) => {
|
||||
copyToClipboard(`${query.value} = ${result.value}`);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: t("search.contextMenu.openFileLocation"),
|
||||
icon: <SquareArrowOutUpRight />,
|
||||
keys: isMac ? ["⌘", "↩︎"] : ["Ctrl", "Enter"],
|
||||
shortcut: isMac ? "meta.enter" : "ctrl.enter",
|
||||
hide: source?.id !== "File Search",
|
||||
clickEvent: async () => {
|
||||
await platformAdapter.revealItemInDir(url);
|
||||
|
||||
platformAdapter.hideWindow();
|
||||
},
|
||||
},
|
||||
];
|
||||
}, [selectedSearchContent, selectedExtension]);
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useAsyncEffect, useDebounce, useKeyPress, useUnmount } from "ahooks";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { CircleCheck, FolderDown, Loader } from "lucide-react";
|
||||
import clsx from "clsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -60,7 +60,7 @@ export interface SearchExtensionItem {
|
||||
views: number;
|
||||
};
|
||||
checksum: string;
|
||||
installed: boolean;
|
||||
installed?: boolean;
|
||||
commands?: Array<{
|
||||
type: string;
|
||||
name: string;
|
||||
@@ -73,7 +73,7 @@ export interface SearchExtensionItem {
|
||||
}>;
|
||||
}
|
||||
|
||||
const ExtensionStore = () => {
|
||||
const ExtensionStore = ({ extensionId }: { extensionId?: string }) => {
|
||||
const {
|
||||
searchValue,
|
||||
selectedExtension,
|
||||
@@ -107,7 +107,26 @@ const ExtensionStore = () => {
|
||||
};
|
||||
}, [selectedExtension]);
|
||||
|
||||
const handleExtensionDetail = useCallback(async () => {
|
||||
try {
|
||||
const detail = await platformAdapter.invokeBackend<SearchExtensionItem>(
|
||||
"extension_detail",
|
||||
{
|
||||
id: extensionId,
|
||||
}
|
||||
);
|
||||
setSelectedExtension(detail);
|
||||
setVisibleExtensionDetail(true);
|
||||
} catch (error) {
|
||||
addError(String(error));
|
||||
}
|
||||
}, [extensionId, installingExtensions]);
|
||||
|
||||
useAsyncEffect(async () => {
|
||||
if (extensionId) {
|
||||
return handleExtensionDetail();
|
||||
}
|
||||
|
||||
const result = await platformAdapter.invokeBackend<SearchExtensionItem[]>(
|
||||
"search_extension",
|
||||
{
|
||||
@@ -125,7 +144,7 @@ const ExtensionStore = () => {
|
||||
setList(result ?? []);
|
||||
|
||||
setSelectedExtension(result?.[0]);
|
||||
}, [debouncedSearchValue]);
|
||||
}, [debouncedSearchValue, extensionId]);
|
||||
|
||||
useUnmount(() => {
|
||||
setSelectedExtension(void 0);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useKeyPress, useSize } from "ahooks";
|
||||
import { useAsyncEffect, useKeyPress, useSize } from "ahooks";
|
||||
import clsx from "clsx";
|
||||
|
||||
import AutoResizeTextarea from "./AutoResizeTextarea";
|
||||
@@ -210,8 +210,8 @@ export default function ChatInput({
|
||||
const extraIconRef = useRef<HTMLDivElement>(null);
|
||||
const extraIconSize = useSize(extraIconRef);
|
||||
|
||||
useEffect(() => {
|
||||
setVisibleAudioInput(isDefaultServer());
|
||||
useAsyncEffect(async () => {
|
||||
setVisibleAudioInput(await isDefaultServer());
|
||||
}, [currentService]);
|
||||
|
||||
const renderSearchIcon = () => (
|
||||
|
||||
0
src/components/Search/ListDesktop.tsx
Normal file
0
src/components/Search/ListDesktop.tsx
Normal file
@@ -1,4 +1,4 @@
|
||||
import { useEffect, memo, useRef } from "react";
|
||||
import { useEffect, memo, useRef, useCallback, useState } from "react";
|
||||
|
||||
import DropdownList from "./DropdownList";
|
||||
import { SearchResults } from "@/components/Search/SearchResults";
|
||||
@@ -10,6 +10,7 @@ import AskAi from "./AskAi";
|
||||
import { useSearch } from "@/hooks/useSearch";
|
||||
import ExtensionStore from "./ExtensionStore";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
import ViewExtension from "./ViewExtension"
|
||||
|
||||
const SearchResultsPanel = memo<{
|
||||
input: string;
|
||||
@@ -36,6 +37,8 @@ const SearchResultsPanel = memo<{
|
||||
performSearch,
|
||||
} = searchState;
|
||||
|
||||
const [extensionId, setExtensionId] = useState<string>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isChatMode && input) {
|
||||
performSearch(input);
|
||||
@@ -44,7 +47,7 @@ const SearchResultsPanel = memo<{
|
||||
}
|
||||
}, [input, isChatMode, performSearch, sourceData]);
|
||||
|
||||
const { setSelectedAssistant, selectedSearchContent, visibleExtensionStore } =
|
||||
const { setSelectedAssistant, selectedSearchContent, visibleExtensionStore, viewExtensionOpened } =
|
||||
useSearchStore();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -58,26 +61,71 @@ const SearchResultsPanel = memo<{
|
||||
}
|
||||
}, [selectedSearchContent]);
|
||||
|
||||
// update state
|
||||
const handleOpenExtensionStore = useCallback(() => {
|
||||
platformAdapter.showWindow();
|
||||
changeMode && changeMode(false);
|
||||
|
||||
if (visibleExtensionStore || visibleExtensionDetail) return;
|
||||
|
||||
changeInput("");
|
||||
setSearchValue("");
|
||||
setVisibleExtensionStore(true);
|
||||
}, [
|
||||
changeMode,
|
||||
visibleExtensionStore,
|
||||
visibleExtensionDetail,
|
||||
changeInput,
|
||||
setSearchValue,
|
||||
setVisibleExtensionStore,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const unlisten = platformAdapter.listenEvent("open-extension-store", () => {
|
||||
platformAdapter.showWindow();
|
||||
changeMode && changeMode(false);
|
||||
const unlisten = platformAdapter.listenEvent(
|
||||
"open-extension-store",
|
||||
handleOpenExtensionStore
|
||||
);
|
||||
const unlisten_install = platformAdapter.listenEvent(
|
||||
"extension_install_success",
|
||||
(event) => {
|
||||
const { extensionId } = event.payload;
|
||||
|
||||
if (visibleExtensionStore || visibleExtensionDetail) return;
|
||||
|
||||
changeInput("");
|
||||
setSearchValue("");
|
||||
setVisibleExtensionStore(true);
|
||||
});
|
||||
setExtensionId(extensionId);
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
unlisten.then((fn) => {
|
||||
fn();
|
||||
});
|
||||
unlisten_install.then((fn) => {
|
||||
fn();
|
||||
});
|
||||
};
|
||||
}, [visibleExtensionStore, visibleExtensionDetail]);
|
||||
}, [handleOpenExtensionStore]);
|
||||
|
||||
useEffect(() => {
|
||||
if (visibleExtensionDetail) return;
|
||||
|
||||
setExtensionId(void 0);
|
||||
}, [visibleExtensionDetail]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!extensionId) return;
|
||||
|
||||
handleOpenExtensionStore();
|
||||
}, [extensionId]);
|
||||
|
||||
// If state gets updated, render the UI
|
||||
if (visibleExtensionStore) {
|
||||
return <ExtensionStore extensionId={extensionId} />;
|
||||
}
|
||||
|
||||
// Render the view extension
|
||||
if (viewExtensionOpened != null) {
|
||||
return <ViewExtension />;
|
||||
}
|
||||
|
||||
if (visibleExtensionStore) return <ExtensionStore />;
|
||||
if (goAskAi) return <AskAi isChatMode={isChatMode} />;
|
||||
if (suggests.length === 0) return <NoResults />;
|
||||
|
||||
|
||||
252
src/components/Search/ViewExtension.tsx
Normal file
252
src/components/Search/ViewExtension.tsx
Normal file
@@ -0,0 +1,252 @@
|
||||
/*
|
||||
* ViewExtension.tsx
|
||||
*
|
||||
* View that will be rendered when opening a View extension.
|
||||
*
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { useSearchStore } from "@/stores/searchStore";
|
||||
import { convertFileSrc, invoke } from "@tauri-apps/api/core";
|
||||
import { ExtensionFileSystemPermission, FileSystemAccess } from "../Settings/Extensions";
|
||||
|
||||
const ViewExtension: React.FC = () => {
|
||||
const { setViewExtensionOpened, viewExtensionOpened } = useSearchStore();
|
||||
const [pagePath, setPagePath] = useState<string>("");
|
||||
// Complete list of the backend APIs, grouped by their category.
|
||||
const [apis, setApis] = useState<Map<string, string[]> | null>(null);
|
||||
|
||||
if (viewExtensionOpened == null) {
|
||||
// When this view gets loaded, this state should not be NULL.
|
||||
throw new Error(
|
||||
"ViewExtension Error: viewExtensionOpened is null. This should not happen."
|
||||
);
|
||||
}
|
||||
|
||||
// Tauri/webview is not allowed to access local files directly,
|
||||
// use convertFileSrc to work around the issue.
|
||||
useEffect(() => {
|
||||
const setupFileUrl = async () => {
|
||||
// The check above ensures viewExtensionOpened is not null here.
|
||||
const filePath = viewExtensionOpened[0];
|
||||
if (filePath) {
|
||||
setPagePath(convertFileSrc(filePath));
|
||||
}
|
||||
};
|
||||
|
||||
setupFileUrl();
|
||||
}, [viewExtensionOpened]);
|
||||
|
||||
// invoke `apis()` and set the state
|
||||
useEffect(() => {
|
||||
const fetchApis = async () => {
|
||||
try {
|
||||
const availableApis = await invoke("apis") as Record<string, string[]>;
|
||||
setApis(new Map(Object.entries(availableApis)));
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch APIs:", error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchApis();
|
||||
}, []);
|
||||
|
||||
const handleBack = () => {
|
||||
setViewExtensionOpened(null);
|
||||
};
|
||||
|
||||
// White list of the permission entries
|
||||
const permission = viewExtensionOpened[1];
|
||||
|
||||
// apis is in format {"category": ["api1", "api2"]}, to make the permission check
|
||||
// easier, reverse the map key values: {"api1": "category", "api2": "category"}
|
||||
const reversedApis = useMemo(() => {
|
||||
if (apis == null) {
|
||||
return null; // Return null instead of throwing error when apis is not ready
|
||||
}
|
||||
|
||||
const reversed = new Map<string, string>();
|
||||
for (const [category, apiArray] of apis.entries()) {
|
||||
for (const api of apiArray) {
|
||||
reversed.set(api, category);
|
||||
}
|
||||
}
|
||||
return reversed;
|
||||
}, [apis]);
|
||||
|
||||
// Watch for events from iframes - only set up listener when reversedApis is ready
|
||||
useEffect(() => {
|
||||
// Don't set up the listener if reversedApis is not ready yet
|
||||
if (!reversedApis) {
|
||||
return;
|
||||
}
|
||||
|
||||
const messageHandler = async (event: MessageEvent) => {
|
||||
if (
|
||||
event.source != null &&
|
||||
typeof (event.source as any).postMessage === "function"
|
||||
) {
|
||||
const source = event.source as Window;
|
||||
const { id, command } = event.data;
|
||||
|
||||
// 1. Check if the command exists
|
||||
if (!reversedApis.has(command)) {
|
||||
source.postMessage(
|
||||
{
|
||||
id,
|
||||
payload: null,
|
||||
error: `Error: Command '${command}' is not a valid API.`,
|
||||
},
|
||||
event.origin
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Check if the extension has permission to call this API
|
||||
const category = reversedApis.get(command)!;
|
||||
var api = null;
|
||||
if (permission == null) {
|
||||
api = null
|
||||
} else {
|
||||
api = permission.api
|
||||
};
|
||||
if (!apiPermissionCheck(category, command, api)) {
|
||||
source.postMessage(
|
||||
{
|
||||
id,
|
||||
payload: null,
|
||||
error: `Error: permission denied, API ${command} is unavailable`,
|
||||
},
|
||||
event.origin
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
var fs = null;
|
||||
if (permission == null) {
|
||||
fs = null
|
||||
} else {
|
||||
fs = permission.fs
|
||||
};
|
||||
if (!(await fsPermissionCheck(command, event.data, fs))) {
|
||||
source.postMessage(
|
||||
{
|
||||
id,
|
||||
payload: null,
|
||||
error: `Error: permission denied`,
|
||||
},
|
||||
event.origin
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === "read_dir") {
|
||||
const { path } = event.data;
|
||||
try {
|
||||
const fileNames: [String] = await invoke("read_dir", { path: path });
|
||||
source.postMessage(
|
||||
{
|
||||
id,
|
||||
payload: fileNames,
|
||||
error: null,
|
||||
},
|
||||
event.origin
|
||||
);
|
||||
} catch (e) {
|
||||
source.postMessage(
|
||||
{
|
||||
id,
|
||||
payload: null,
|
||||
error: e,
|
||||
},
|
||||
event.origin
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener("message", messageHandler);
|
||||
console.info("Coco extension API listener is up");
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('message', messageHandler);
|
||||
};
|
||||
}, [reversedApis, permission]); // Add apiPermissions as dependency
|
||||
|
||||
return (
|
||||
<div className="h-full w-full flex flex-col">
|
||||
{/* Header with back button */}
|
||||
<div className="flex items-center p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
onClick={handleBack}
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors text-gray-600 dark:text-gray-300"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
<span>Back to Search</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex-1">
|
||||
<iframe
|
||||
src={pagePath}
|
||||
className="w-full h-full border-0"
|
||||
>
|
||||
</iframe>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ViewExtension;
|
||||
|
||||
|
||||
// Permission check function - TypeScript translation of Rust function
|
||||
const apiPermissionCheck = (category: string, api: string, allowedApis: string[] | null): boolean => {
|
||||
if (!allowedApis) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const qualifiedApi = `${category}:${api}`;
|
||||
return allowedApis.some(a => a === qualifiedApi);
|
||||
};
|
||||
|
||||
const extractFsAccessPattern = (command: string, requestPayload: any): [string, FileSystemAccess] => {
|
||||
switch (command) {
|
||||
case "read_dir": {
|
||||
const { path } = requestPayload;
|
||||
|
||||
return [path, ["read"]];
|
||||
}
|
||||
default: {
|
||||
throw new Error(`unknown command ${command}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fsPermissionCheck = async (command: string, requestPayload: any, fsPermission: ExtensionFileSystemPermission[] | null): Promise<boolean> => {
|
||||
if (!fsPermission) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const [ path, access ] = extractFsAccessPattern(command, requestPayload);
|
||||
const clean_path = await invoke("path_absolute", { path: path });
|
||||
|
||||
// Walk through fsPermission array to find matching paths
|
||||
for (const permission of fsPermission) {
|
||||
if (permission.path === clean_path) {
|
||||
// Check if all required access permissions are included in the permission's access array
|
||||
const hasAllRequiredAccess = access.every(requiredAccess =>
|
||||
permission.access.includes(requiredAccess)
|
||||
);
|
||||
|
||||
if (hasAllRequiredAccess) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -21,7 +21,7 @@ import { isLinux, isWin } from "@/utils/platform";
|
||||
import { appReducer, initialAppState } from "@/reducers/appReducer";
|
||||
import { useWindowEvents } from "@/hooks/useWindowEvents";
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
import { useAuthStore } from "@/stores/authStore";
|
||||
import { useSearchStore } from "@/stores/searchStore";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
import { useStartupStore } from "@/stores/startupStore";
|
||||
import { useThemeStore } from "@/stores/themeStore";
|
||||
@@ -105,13 +105,10 @@ function SearchChat({
|
||||
|
||||
const [isWin10, setIsWin10] = useState(false);
|
||||
const blurred = useAppStore((state) => state.blurred);
|
||||
const { viewExtensionOpened } = useSearchStore();
|
||||
|
||||
useWindowEvents();
|
||||
|
||||
const initializeListeners_auth = useAuthStore((state) => {
|
||||
return state.initializeListeners;
|
||||
});
|
||||
|
||||
const setTheme = useThemeStore((state) => state.setTheme);
|
||||
const setIsDark = useThemeStore((state) => state.setIsDark);
|
||||
|
||||
@@ -128,7 +125,6 @@ function SearchChat({
|
||||
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
await initializeListeners_auth();
|
||||
if (isTauri) {
|
||||
await platformAdapter.commands("get_app_search_source");
|
||||
}
|
||||
@@ -293,41 +289,45 @@ function SearchChat({
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
<div
|
||||
data-tauri-drag-region={isTauri}
|
||||
className={`p-2 w-full flex justify-center transition-all duration-500 min-h-[82px] ${
|
||||
isTransitioned ? "border-t" : "border-b"
|
||||
} border-[#E6E6E6] dark:border-[#272626]`}
|
||||
>
|
||||
<InputBox
|
||||
isChatMode={isChatMode}
|
||||
inputValue={input}
|
||||
onSend={handleSendMessage}
|
||||
disabled={isTyping}
|
||||
disabledChange={cancelChat}
|
||||
changeMode={changeMode}
|
||||
changeInput={setInput}
|
||||
isSearchActive={isSearchActive}
|
||||
setIsSearchActive={toggleSearchActive}
|
||||
isDeepThinkActive={isDeepThinkActive}
|
||||
setIsDeepThinkActive={toggleDeepThinkActive}
|
||||
isMCPActive={isMCPActive}
|
||||
setIsMCPActive={toggleMCPActive}
|
||||
setupWindowFocusListener={setupWindowFocusListener}
|
||||
checkScreenPermission={checkScreenPermission}
|
||||
requestScreenPermission={requestScreenPermission}
|
||||
getScreenMonitors={getScreenMonitors}
|
||||
getScreenWindows={getScreenWindows}
|
||||
captureMonitorScreenshot={captureMonitorScreenshot}
|
||||
captureWindowScreenshot={captureWindowScreenshot}
|
||||
openFileDialog={openFileDialog}
|
||||
getFileMetadata={getFileMetadata}
|
||||
getFileIcon={getFileIcon}
|
||||
hasModules={hasModules}
|
||||
searchPlaceholder={searchPlaceholder}
|
||||
chatPlaceholder={chatPlaceholder}
|
||||
/>
|
||||
</div>
|
||||
{/* We don't want this inputbox when rendering View extensions */}
|
||||
{/* TODO: figure out a better way to disable this inputbox */}
|
||||
{!viewExtensionOpened && (
|
||||
<div
|
||||
data-tauri-drag-region={isTauri}
|
||||
className={`p-2 w-full flex justify-center transition-all duration-500 min-h-[82px] ${
|
||||
isTransitioned ? "border-t" : "border-b"
|
||||
} border-[#E6E6E6] dark:border-[#272626]`}
|
||||
>
|
||||
<InputBox
|
||||
isChatMode={isChatMode}
|
||||
inputValue={input}
|
||||
onSend={handleSendMessage}
|
||||
disabled={isTyping}
|
||||
disabledChange={cancelChat}
|
||||
changeMode={changeMode}
|
||||
changeInput={setInput}
|
||||
isSearchActive={isSearchActive}
|
||||
setIsSearchActive={toggleSearchActive}
|
||||
isDeepThinkActive={isDeepThinkActive}
|
||||
setIsDeepThinkActive={toggleDeepThinkActive}
|
||||
isMCPActive={isMCPActive}
|
||||
setIsMCPActive={toggleMCPActive}
|
||||
setupWindowFocusListener={setupWindowFocusListener}
|
||||
checkScreenPermission={checkScreenPermission}
|
||||
requestScreenPermission={requestScreenPermission}
|
||||
getScreenMonitors={getScreenMonitors}
|
||||
getScreenWindows={getScreenWindows}
|
||||
captureMonitorScreenshot={captureMonitorScreenshot}
|
||||
captureWindowScreenshot={captureWindowScreenshot}
|
||||
openFileDialog={openFileDialog}
|
||||
getFileMetadata={getFileMetadata}
|
||||
getFileIcon={getFileIcon}
|
||||
hasModules={hasModules}
|
||||
searchPlaceholder={searchPlaceholder}
|
||||
chatPlaceholder={chatPlaceholder}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
data-tauri-drag-region={isTauri}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FC, useMemo, useState, useCallback } from "react";
|
||||
import { FC, useMemo, useState, useCallback, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { isArray } from "lodash-es";
|
||||
import { useAsyncEffect, useMount } from "ahooks";
|
||||
@@ -37,6 +37,13 @@ const SharedAi: FC<SharedAiProps> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const [assistantSearchValue, setAssistantSearchValue] = useState("");
|
||||
const [isLoadingAssistants, setIsLoadingAssistants] = useState(false);
|
||||
const { setCloudSelectService } = useConnectStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (!server) return;
|
||||
|
||||
setCloudSelectService(server);
|
||||
}, [server]);
|
||||
|
||||
const getEnabledServers = useCallback((servers: Server[]): Server[] => {
|
||||
if (!isArray(servers)) return [];
|
||||
@@ -56,7 +63,9 @@ const SharedAi: FC<SharedAiProps> = (props) => {
|
||||
}
|
||||
|
||||
if (server) {
|
||||
const matchServer = enabledServers.find((item) => item.id === server.id);
|
||||
const matchServer = enabledServers.find(
|
||||
(item) => item.id === server.id
|
||||
);
|
||||
if (matchServer) {
|
||||
setServer(matchServer);
|
||||
return;
|
||||
@@ -65,7 +74,7 @@ const SharedAi: FC<SharedAiProps> = (props) => {
|
||||
|
||||
setServer(enabledServers[0]);
|
||||
} catch (error) {
|
||||
console.error('Failed to load servers:', error);
|
||||
console.error("Failed to load servers:", error);
|
||||
addError(`Failed to load servers: ${String(error)}`);
|
||||
}
|
||||
});
|
||||
@@ -86,7 +95,9 @@ const SharedAi: FC<SharedAiProps> = (props) => {
|
||||
query: assistantSearchValue,
|
||||
});
|
||||
|
||||
const assistants: Assistant[] = data.list.map((item: any) => item._source);
|
||||
const assistants: Assistant[] = data.list.map(
|
||||
(item: any) => item._source
|
||||
);
|
||||
setAssistantList(assistants);
|
||||
|
||||
if (assistants.length === 0) {
|
||||
@@ -104,7 +115,7 @@ const SharedAi: FC<SharedAiProps> = (props) => {
|
||||
|
||||
setAssistant(assistants[0]);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch assistants:', error);
|
||||
console.error("Failed to fetch assistants:", error);
|
||||
addError(`Failed to fetch assistants: ${String(error)}`);
|
||||
setAssistantList([]);
|
||||
setAssistant(undefined);
|
||||
@@ -181,7 +192,9 @@ const SharedAi: FC<SharedAiProps> = (props) => {
|
||||
searchable={searchable}
|
||||
onChange={onChange}
|
||||
onSearch={onSearch}
|
||||
placeholder={isLoadingAssistants && searchable ? "Loading..." : undefined}
|
||||
placeholder={
|
||||
isLoadingAssistants && searchable ? "Loading..." : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -8,6 +8,11 @@ import SharedAi from "./SharedAi";
|
||||
import AiOverview from "./AiOverview";
|
||||
import Calculator from "./Calculator";
|
||||
import FileSearch from "./FileSearch";
|
||||
import { Ellipsis } from "lucide-react";
|
||||
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const Details = () => {
|
||||
const { rootState } = useContext(ExtensionsContext);
|
||||
@@ -23,6 +28,10 @@ const Details = () => {
|
||||
const setQuickAiAccessAssistant = useExtensionsStore((state) => {
|
||||
return state.setQuickAiAccessAssistant;
|
||||
});
|
||||
const addError = useAppStore((state) => {
|
||||
return state.addError;
|
||||
});
|
||||
const { t } = useTranslation();
|
||||
|
||||
const renderContent = () => {
|
||||
if (!rootState.activeExtension) return;
|
||||
@@ -66,12 +75,62 @@ const Details = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 h-full overflow-auto">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
{rootState.activeExtension?.name}
|
||||
</h2>
|
||||
<div className="flex-1 h-full pr-4 pb-4 overflow-auto">
|
||||
<div className="flex items-start justify-between gap-4 mb-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{rootState.activeExtension?.name}
|
||||
</h2>
|
||||
|
||||
<div className="pr-4 pb-4 text-sm">{renderContent()}</div>
|
||||
{rootState.activeExtension?.developer && (
|
||||
<Menu>
|
||||
<MenuButton className="h-7">
|
||||
<Ellipsis className="size-5 text-[#999]" />
|
||||
</MenuButton>
|
||||
|
||||
<MenuItems
|
||||
anchor="bottom end"
|
||||
className="p-1 text-sm bg-white dark:bg-[#202126] rounded-lg shadow-xs border border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
<MenuItem>
|
||||
<div
|
||||
className="px-3 py-2 text-nowrap text-red-500 hover:bg-black/5 hover:dark:bg-white/5 rounded-lg cursor-pointer"
|
||||
onClick={async () => {
|
||||
try {
|
||||
const { id, developer } = rootState.activeExtension!;
|
||||
|
||||
await platformAdapter.invokeBackend(
|
||||
"uninstall_extension",
|
||||
{
|
||||
extensionId: id,
|
||||
developer: developer,
|
||||
}
|
||||
);
|
||||
|
||||
Object.assign(rootState, {
|
||||
activeExtension: void 0,
|
||||
extensions: rootState.extensions.filter((item) => {
|
||||
return item.id !== id;
|
||||
}),
|
||||
});
|
||||
|
||||
addError(
|
||||
t("settings.extensions.hints.uninstallSuccess"),
|
||||
"info"
|
||||
);
|
||||
} catch (error) {
|
||||
addError(String(error));
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("settings.extensions.hints.uninstall")}
|
||||
</div>
|
||||
</MenuItem>
|
||||
</MenuItems>
|
||||
</Menu>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-sm">{renderContent()}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -45,6 +45,23 @@ interface ExtensionQuicklink {
|
||||
link: string;
|
||||
}
|
||||
|
||||
export type FileSystemAccess = ("read" | "write")[];
|
||||
|
||||
export interface ExtensionFileSystemPermission {
|
||||
path: string;
|
||||
access: FileSystemAccess;
|
||||
}
|
||||
|
||||
export interface ExtensionHttpPermission {
|
||||
host: string;
|
||||
}
|
||||
|
||||
export interface ExtensionPermission {
|
||||
fs: ExtensionFileSystemPermission[] | null;
|
||||
http: ExtensionHttpPermission[] | null;
|
||||
api: string[] | null;
|
||||
}
|
||||
|
||||
export interface Extension {
|
||||
id: ExtensionId;
|
||||
type: ExtensionType;
|
||||
@@ -60,8 +77,11 @@ export interface Extension {
|
||||
commands?: Extension[];
|
||||
scripts?: Extension[];
|
||||
quicklinks?: Extension[];
|
||||
views?: Extension[];
|
||||
settings: Record<string, unknown>;
|
||||
developer?: string;
|
||||
page?: string;
|
||||
permission?: ExtensionPermission;
|
||||
}
|
||||
|
||||
type Category = LiteralUnion<
|
||||
@@ -205,9 +225,15 @@ export const Extensions = () => {
|
||||
const errorMessage = String(error);
|
||||
|
||||
if (errorMessage === "already imported") {
|
||||
addError(t("settings.extensions.hints.extensionAlreadyImported"));
|
||||
addError(
|
||||
t(
|
||||
"settings.extensions.hints.extensionAlreadyImported"
|
||||
)
|
||||
);
|
||||
} else if (errorMessage === "incompatible") {
|
||||
addError(t("settings.extensions.hints.incompatibleExtension"));
|
||||
addError(
|
||||
t("settings.extensions.hints.incompatibleExtension")
|
||||
);
|
||||
} else {
|
||||
addError(t("settings.extensions.hints.importFailed"));
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
change_shortcut,
|
||||
unregister_shortcut,
|
||||
} from "@/commands";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
|
||||
export function ThemeOption({
|
||||
icon: Icon,
|
||||
@@ -167,8 +168,6 @@ export default function GeneralSettings() {
|
||||
};
|
||||
|
||||
// const clearAllCache = useCallback(() => {
|
||||
// setAuth(undefined, endpoint);
|
||||
// setUserInfo({}, endpoint);
|
||||
|
||||
// useConnectStore.persist.clearStorage();
|
||||
|
||||
@@ -248,8 +247,12 @@ export default function GeneralSettings() {
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={currentLanguage}
|
||||
onChange={(e) => {
|
||||
setLanguage(e.currentTarget.value);
|
||||
onChange={(event) => {
|
||||
const lang = event.currentTarget.value;
|
||||
|
||||
setLanguage(lang);
|
||||
|
||||
platformAdapter.invokeBackend("update_app_lang", { lang });
|
||||
}}
|
||||
className="px-3 py-1.5 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
|
||||
@@ -131,14 +131,17 @@ const UpdateApp = ({ isCheckPage }: UpdateAppProps) => {
|
||||
|
||||
const { skipVersions, updateInfo } = useUpdateStore.getState();
|
||||
|
||||
if(updateInfo?.version){
|
||||
setSkipVersions([...skipVersions, updateInfo.version]);
|
||||
|
||||
}
|
||||
|
||||
isCheckPage ? hide_check() : setVisible(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={visible}
|
||||
open={isCheckPage ? true : visible}
|
||||
as="div"
|
||||
className="relative z-10 focus:outline-none"
|
||||
onClose={noop}
|
||||
|
||||
@@ -9,3 +9,7 @@ export const DEFAULT_COCO_SERVER_ID = "default_coco_server";
|
||||
export const MAIN_WINDOW_LABEL = "main";
|
||||
|
||||
export const SETTINGS_WINDOW_LABEL = "settings";
|
||||
|
||||
export const CHECK_WINDOW_LABEL = "check";
|
||||
|
||||
export const CHAT_WINDOW_LABEL = "chat";
|
||||
|
||||
@@ -471,7 +471,7 @@ export function useChatActions(
|
||||
const getChatHistory = useCallback(async () => {
|
||||
let response: any;
|
||||
if (isTauri) {
|
||||
if (unrequitable()) {
|
||||
if (await unrequitable()) {
|
||||
return setChats([]);
|
||||
}
|
||||
|
||||
|
||||
179
src/hooks/useDeepLinkManager.ts
Normal file
179
src/hooks/useDeepLinkManager.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import {
|
||||
getCurrent as getCurrentDeepLinkUrls,
|
||||
onOpenUrl,
|
||||
} from "@tauri-apps/plugin-deep-link";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
import { useConnectStore } from "@/stores/connectStore";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MAIN_WINDOW_LABEL } from "@/constants";
|
||||
import { useAsyncEffect, useEventListener } from "ahooks";
|
||||
|
||||
export interface DeepLinkHandler {
|
||||
pattern: string;
|
||||
handler: (url: URL) => Promise<void> | void;
|
||||
}
|
||||
|
||||
export function useDeepLinkManager() {
|
||||
const addError = useAppStore((state) => state.addError);
|
||||
const { t } = useTranslation();
|
||||
|
||||
// handle oauth callback
|
||||
const handleOAuthCallback = useCallback(async (url: URL) => {
|
||||
try {
|
||||
const reqId = url.searchParams.get("request_id");
|
||||
const code = url.searchParams.get("code");
|
||||
|
||||
const { ssoRequestID } = useAppStore.getState();
|
||||
const { cloudSelectService } = useConnectStore.getState();
|
||||
|
||||
if (reqId !== ssoRequestID) {
|
||||
console.log("Request ID not matched, skip");
|
||||
addError("Request ID not matched, skip");
|
||||
return;
|
||||
}
|
||||
|
||||
const serverId = cloudSelectService?.id;
|
||||
if (!code || !serverId) {
|
||||
addError("No authorization code received");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("Handling OAuth callback:", { code, serverId });
|
||||
await platformAdapter.commands("handle_sso_callback", {
|
||||
serverId: serverId,
|
||||
requestId: ssoRequestID,
|
||||
code: code,
|
||||
});
|
||||
|
||||
// trigger oauth success event
|
||||
platformAdapter.emitEvent("oauth_success", { serverId });
|
||||
getCurrentWindow().setFocus();
|
||||
} catch (err) {
|
||||
console.error("Failed to parse OAuth callback URL:", err);
|
||||
addError("Invalid OAuth callback URL format: " + err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// handle install extension from store
|
||||
const handleInstallExtension = useCallback(async (url: URL) => {
|
||||
const extensionId = url.searchParams.get("id");
|
||||
if (!extensionId) {
|
||||
return console.warn(
|
||||
'received an invalid install_extension_from_store deeplink, missing argument "id"'
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await platformAdapter.showWindow();
|
||||
|
||||
await invoke("install_extension_from_store", { id: extensionId });
|
||||
|
||||
// trigger extension install success event
|
||||
platformAdapter.emitEvent("extension_install_success", { extensionId });
|
||||
addError(t("deepLink.extensionInstallSuccessfully"), "info");
|
||||
console.log("Extension installed successfully:", extensionId);
|
||||
} catch (error) {
|
||||
addError(String(error));
|
||||
}
|
||||
}, []);
|
||||
|
||||
// handle deep link
|
||||
const handlers: DeepLinkHandler[] = [
|
||||
{
|
||||
pattern: "oauth_callback",
|
||||
handler: handleOAuthCallback,
|
||||
},
|
||||
{
|
||||
pattern: "install_extension_from_store",
|
||||
handler: async (url) => {
|
||||
const windowLabel = await platformAdapter.getCurrentWindowLabel();
|
||||
|
||||
if (windowLabel !== MAIN_WINDOW_LABEL) return;
|
||||
|
||||
handleInstallExtension(url);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// handle deep link
|
||||
const handleUrl = useCallback(
|
||||
(url: string) => {
|
||||
console.debug("handling deeplink URL", url);
|
||||
|
||||
try {
|
||||
const urlObject = new URL(url.trim());
|
||||
const deeplinkIdentifier = urlObject.hostname;
|
||||
|
||||
// find handler by pattern
|
||||
const handler = handlers.find((h) => h.pattern === deeplinkIdentifier);
|
||||
|
||||
if (handler) {
|
||||
handler.handler(urlObject);
|
||||
} else {
|
||||
console.error("Unknown deep link:", url);
|
||||
addError("Unknown deep link: " + url);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to parse URL:", err);
|
||||
addError("Invalid URL format: " + err);
|
||||
}
|
||||
},
|
||||
[handlers]
|
||||
);
|
||||
|
||||
// handle paste text
|
||||
const handlePaste = useCallback(
|
||||
(event: ClipboardEvent) => {
|
||||
const pastedText = event.clipboardData?.getData("text")?.trim();
|
||||
console.log("handle paste text:", pastedText);
|
||||
|
||||
// coco://oauth_callback
|
||||
if (pastedText && pastedText.startsWith("coco://oauth_callback")) {
|
||||
console.log("handle deeplink on paste:", pastedText);
|
||||
handleUrl(pastedText);
|
||||
}
|
||||
},
|
||||
[handleUrl]
|
||||
);
|
||||
|
||||
// get initial deep link
|
||||
useAsyncEffect(async () => {
|
||||
try {
|
||||
const urls = await getCurrentDeepLinkUrls();
|
||||
|
||||
console.log("Initial DeepLinkUrls:", urls);
|
||||
|
||||
if (urls && urls.length > 0) {
|
||||
handleUrl(urls[0]);
|
||||
}
|
||||
} catch (error) {
|
||||
addError("Failed to get initial URLs: " + error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// handle deep link on paste
|
||||
useEffect(() => {
|
||||
// handle new deep link
|
||||
const unlisten = onOpenUrl((urls) => {
|
||||
console.log("onOpenUrl urls", urls);
|
||||
|
||||
handleUrl(urls[0]);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unlisten.then((fn) => fn());
|
||||
};
|
||||
}, []);
|
||||
|
||||
// add paste event listener
|
||||
useEventListener("paste", handlePaste);
|
||||
|
||||
return {
|
||||
handleUrl,
|
||||
};
|
||||
}
|
||||
@@ -37,6 +37,7 @@ export function useKeyboardNavigation({
|
||||
const modifierKey = useShortcutsStore((state) => {
|
||||
return state.modifierKey;
|
||||
});
|
||||
const { setSelectedSearchContent } = useSearchStore();
|
||||
|
||||
const getModifierKeyPressed = (event: KeyboardEvent) => {
|
||||
const metaKeyPressed = event.metaKey && modifierKey === "meta";
|
||||
@@ -123,6 +124,8 @@ export function useKeyboardNavigation({
|
||||
|
||||
const item = globalItemIndexMap[index];
|
||||
|
||||
setSelectedSearchContent(item);
|
||||
|
||||
platformAdapter.openSearchItem(item, formatUrl);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -30,5 +30,5 @@ export const useIconfontScript = () => {
|
||||
// Coco Server Icons
|
||||
useScript("https://at.alicdn.com/t/c/font_4878526_cykw3et0ezd.js");
|
||||
// Coco App Icons
|
||||
useScript("https://at.alicdn.com/t/c/font_4934333_zclkkzo4fgo.js");
|
||||
useScript("https://at.alicdn.com/t/c/font_4934333_0u00aavw7iob.js");
|
||||
};
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
getCurrentWindowService,
|
||||
setCurrentWindowService,
|
||||
handleLogout,
|
||||
} from "@/commands/servers";
|
||||
} from "@/commands/windowService";
|
||||
|
||||
export const useServers = () => {
|
||||
const setServerList = useConnectStore((state) => state.setServerList);
|
||||
|
||||
@@ -117,6 +117,7 @@ export const useSyncStore = () => {
|
||||
const setShowTooltip = useAppStore((state) => state.setShowTooltip);
|
||||
const setEndpoint = useAppStore((state) => state.setEndpoint);
|
||||
const setLanguage = useAppStore((state) => state.setLanguage);
|
||||
const { setCurrentService } = useConnectStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (!resetFixedWindow) {
|
||||
@@ -180,8 +181,12 @@ export const useSyncStore = () => {
|
||||
}),
|
||||
|
||||
platformAdapter.listenEvent("change-connect-store", ({ payload }) => {
|
||||
const { connectionTimeout, querySourceTimeout, allowSelfSignature } =
|
||||
payload;
|
||||
const {
|
||||
connectionTimeout,
|
||||
querySourceTimeout,
|
||||
allowSelfSignature,
|
||||
currentService,
|
||||
} = payload;
|
||||
if (isNumber(connectionTimeout)) {
|
||||
setConnectionTimeout(connectionTimeout);
|
||||
}
|
||||
@@ -189,6 +194,7 @@ export const useSyncStore = () => {
|
||||
setQueryTimeout(querySourceTimeout);
|
||||
}
|
||||
setAllowSelfSignature(allowSelfSignature);
|
||||
setCurrentService(currentService);
|
||||
}),
|
||||
|
||||
platformAdapter.listenEvent("change-appearance-store", ({ payload }) => {
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
|
||||
interface UseTogglePinOptions {
|
||||
onPinChange?: (isPinned: boolean) => void;
|
||||
}
|
||||
|
||||
export const useTogglePin = (options?: UseTogglePinOptions) => {
|
||||
const { isPinned, setIsPinned } = useAppStore();
|
||||
|
||||
const togglePin = useCallback(async () => {
|
||||
try {
|
||||
const newPinned = !isPinned;
|
||||
|
||||
if (options?.onPinChange) {
|
||||
options.onPinChange(newPinned);
|
||||
}
|
||||
|
||||
await platformAdapter.setAlwaysOnTop(newPinned);
|
||||
await platformAdapter.toggleMoveToActiveSpaceAttribute();
|
||||
setIsPinned(newPinned);
|
||||
} catch (err) {
|
||||
console.error("Failed to toggle window pin state:", err);
|
||||
}
|
||||
}, [isPinned, setIsPinned, options?.onPinChange]);
|
||||
|
||||
return {
|
||||
isPinned,
|
||||
togglePin,
|
||||
};
|
||||
};
|
||||
@@ -209,7 +209,9 @@
|
||||
"importSuccess": "Extension imported successfully.",
|
||||
"importFailed": "No valid extension found in the selected folder. Please check the folder structure.",
|
||||
"extensionAlreadyImported": "Extension already imported. Please remove it first.",
|
||||
"incompatibleExtension": "This extension is incompatible with your OS."
|
||||
"incompatibleExtension": "This extension is incompatible with your OS.",
|
||||
"uninstall": "Uninstall",
|
||||
"uninstallSuccess": "Uninstalled successfully"
|
||||
},
|
||||
"application": {
|
||||
"title": "Applications",
|
||||
@@ -377,7 +379,8 @@
|
||||
"details": "Details",
|
||||
"install": "Install",
|
||||
"uninstall": "Uninstall",
|
||||
"configureExtension": "Configure Extension"
|
||||
"configureExtension": "Configure Extension",
|
||||
"openFileLocation": "Open File Location"
|
||||
},
|
||||
"askCocoAi": {
|
||||
"title": "{{0}} {{1}}",
|
||||
@@ -405,8 +408,8 @@
|
||||
"error": "Request error. Please try again later.",
|
||||
"logo_alt": "Login Logo",
|
||||
"welcome": "Welcome to Coco AI",
|
||||
"connect_tip": "To start a conversation, please connect to the service and log in to your account.",
|
||||
"connect": "Connect"
|
||||
"connect_tip": "To start a conversation, please log in to your account.",
|
||||
"connect": "Login"
|
||||
},
|
||||
"input": {
|
||||
"stopMessage": "Stop message",
|
||||
@@ -571,5 +574,8 @@
|
||||
"cancel": "Cancel",
|
||||
"delete": "Delete"
|
||||
}
|
||||
},
|
||||
"deepLink": {
|
||||
"extensionInstallSuccessfully": "Extension installed successfully."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,7 +209,9 @@
|
||||
"importSuccess": "插件导入成功。",
|
||||
"importFailed": "未在该目录中找到有效的插件,请检查目录结构是否正确。",
|
||||
"extensionAlreadyImported": "插件已存在,无法重复导入。请先将其删除后再尝试。",
|
||||
"incompatibleExtension": "此插件与当前操作系统不兼容。"
|
||||
"incompatibleExtension": "此插件与当前操作系统不兼容。",
|
||||
"uninstall": "卸载",
|
||||
"uninstallSuccess": "卸载成功"
|
||||
},
|
||||
"application": {
|
||||
"title": "应用程序",
|
||||
@@ -377,7 +379,8 @@
|
||||
"details": "详情",
|
||||
"install": "安装",
|
||||
"uninstall": "卸载",
|
||||
"configureExtension": "配置扩展"
|
||||
"configureExtension": "配置扩展",
|
||||
"openFileLocation": "打开所在文件夹"
|
||||
},
|
||||
"askCocoAi": {
|
||||
"title": "{{0}}{{1}}",
|
||||
@@ -405,8 +408,8 @@
|
||||
"error": "请求错误,请稍后再试。",
|
||||
"logo_alt": "登录图标",
|
||||
"welcome": "欢迎使用 Coco AI",
|
||||
"connect_tip": "要开始对话,请连接服务并登录您的账户。",
|
||||
"connect": "连接"
|
||||
"connect_tip": "要开始对话,请登录您的账户。",
|
||||
"connect": "登录"
|
||||
},
|
||||
"input": {
|
||||
"stopMessage": "停止生成",
|
||||
@@ -570,5 +573,8 @@
|
||||
"cancel": "取消",
|
||||
"delete": "删除"
|
||||
}
|
||||
},
|
||||
"deepLink": {
|
||||
"extensionInstallSuccessfully": "扩展安装成功。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ export default function StandaloneChat({}: StandaloneChatProps) {
|
||||
|
||||
const getChatHistory = async () => {
|
||||
try {
|
||||
if (unrequitable()) {
|
||||
if (await unrequitable()) {
|
||||
return setChats([]);
|
||||
}
|
||||
|
||||
@@ -271,7 +271,11 @@ export default function StandaloneChat({}: StandaloneChatProps) {
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!currentService?.id) return;
|
||||
|
||||
await platformAdapter.commands("delete_session_chat", currentService.id, id);
|
||||
await platformAdapter.commands(
|
||||
"delete_session_chat",
|
||||
currentService.id,
|
||||
id
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,18 +1,9 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { useUpdateStore } from "@/stores/updateStore";
|
||||
import UpdateApp from "@/components/UpdateApp";
|
||||
import { useSyncStore } from "@/hooks/useSyncStore";
|
||||
|
||||
const CheckApp = () => {
|
||||
const { setVisible } = useUpdateStore();
|
||||
|
||||
useSyncStore();
|
||||
|
||||
useEffect(() => {
|
||||
setVisible(true);
|
||||
}, []);
|
||||
|
||||
return <UpdateApp isCheckPage />;
|
||||
};
|
||||
|
||||
|
||||
@@ -6,11 +6,24 @@ import { useSyncStore } from "@/hooks/useSyncStore";
|
||||
import UpdateApp from "@/components/UpdateApp";
|
||||
import Synthesize from "@/components/Assistant/Synthesize";
|
||||
import { useChatStore } from "@/stores/chatStore";
|
||||
import { useSearchStore } from "@/stores/searchStore";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
|
||||
function MainApp() {
|
||||
const { setIsTauri } = useAppStore();
|
||||
const { setViewExtensionOpened } = useSearchStore();
|
||||
|
||||
useEffect(() => {
|
||||
setIsTauri(true);
|
||||
|
||||
// Set up the listener that listens for "open_view_extension" events
|
||||
//
|
||||
// Events will be sent when users try to open a View extension via hotkey,
|
||||
// whose payload contains the needed information to load the View page.
|
||||
platformAdapter.listenEvent("open_view_extension", async ({ payload: view_extension_page_and_permission } ) => {
|
||||
await platformAdapter.showWindow();
|
||||
setViewExtensionOpened(view_extension_page_and_permission);
|
||||
})
|
||||
}, []);
|
||||
const { synthesizeItem } = useChatStore();
|
||||
|
||||
|
||||
@@ -1,147 +1,23 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Outlet, useLocation } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
useAsyncEffect,
|
||||
useEventListener,
|
||||
useMount,
|
||||
useTextSelection,
|
||||
} from "ahooks";
|
||||
import { isArray, isString } from "lodash-es";
|
||||
import { useMount } from "ahooks";
|
||||
import { useState } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
import LayoutOutlet from "./outlet";
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
import useEscape from "@/hooks/useEscape";
|
||||
import useSettingsWindow from "@/hooks/useSettingsWindow";
|
||||
import { useThemeStore } from "@/stores/themeStore";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
import { AppTheme } from "@/types/index";
|
||||
import ErrorNotification from "@/components/Common/ErrorNotification";
|
||||
import { useModifierKeyPress } from "@/hooks/useModifierKeyPress";
|
||||
import { useIconfontScript } from "@/hooks/useScript";
|
||||
import { Extension } from "@/components/Settings/Extensions";
|
||||
import { useExtensionsStore } from "@/stores/extensionsStore";
|
||||
import { useServers } from "@/hooks/useServers";
|
||||
|
||||
export default function Layout() {
|
||||
const location = useLocation();
|
||||
|
||||
const Layout = () => {
|
||||
const { language } = useAppStore();
|
||||
const { i18n } = useTranslation();
|
||||
const { activeTheme, isDark, setIsDark, setTheme } = useThemeStore();
|
||||
|
||||
// init servers isTauri
|
||||
useServers();
|
||||
|
||||
const [langUpdated, setLangUpdated] = useState(false);
|
||||
|
||||
useAsyncEffect(async () => {
|
||||
i18n.changeLanguage(language);
|
||||
|
||||
await platformAdapter.invokeBackend("update_app_lang", {
|
||||
lang: language,
|
||||
});
|
||||
|
||||
setLangUpdated(true);
|
||||
}, [language]);
|
||||
|
||||
function updateBodyClass(path: string) {
|
||||
const body = document.body;
|
||||
body.classList.remove("input-body");
|
||||
|
||||
if (path === "/ui") {
|
||||
body.classList.add("input-body");
|
||||
}
|
||||
}
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
useMount(async () => {
|
||||
await platformAdapter.setShadow(true);
|
||||
|
||||
const unlistenTheme = await platformAdapter.listenThemeChanged(
|
||||
(theme: AppTheme) => {
|
||||
setTheme(theme);
|
||||
setIsDark(theme === "dark");
|
||||
}
|
||||
);
|
||||
|
||||
platformAdapter.onThemeChanged(({ payload }) => {
|
||||
if (activeTheme !== "auto") return;
|
||||
|
||||
setIsDark(payload === "dark");
|
||||
await invoke("backend_setup", {
|
||||
appLang: language,
|
||||
});
|
||||
|
||||
return () => {
|
||||
unlistenTheme();
|
||||
};
|
||||
setReady(true);
|
||||
});
|
||||
|
||||
useAsyncEffect(async () => {
|
||||
let nextTheme: any = activeTheme === "auto" ? null : activeTheme;
|
||||
return ready && <LayoutOutlet />;
|
||||
};
|
||||
|
||||
await platformAdapter.setWindowTheme(nextTheme);
|
||||
|
||||
nextTheme = nextTheme ?? (await platformAdapter.getWindowTheme());
|
||||
|
||||
setIsDark(nextTheme === "dark");
|
||||
}, [activeTheme]);
|
||||
|
||||
useEffect(() => {
|
||||
const theme = isDark ? "dark" : "light";
|
||||
const root = window.document.documentElement;
|
||||
|
||||
root.className = theme;
|
||||
root.dataset.theme = theme;
|
||||
}, [isDark]);
|
||||
|
||||
useEffect(() => {
|
||||
updateBodyClass(location.pathname);
|
||||
}, [location.pathname]);
|
||||
|
||||
useEscape();
|
||||
|
||||
useSettingsWindow();
|
||||
|
||||
const { text: selectionText } = useTextSelection();
|
||||
|
||||
// Disable right-click for production environment
|
||||
useEventListener("contextmenu", (event) => {
|
||||
if (import.meta.env.DEV || selectionText) return;
|
||||
|
||||
event.preventDefault();
|
||||
});
|
||||
|
||||
useModifierKeyPress();
|
||||
|
||||
useEventListener("unhandledrejection", ({ reason }) => {
|
||||
const message = isString(reason) ? reason : JSON.stringify(reason);
|
||||
|
||||
platformAdapter.error(message);
|
||||
});
|
||||
|
||||
useIconfontScript();
|
||||
|
||||
const setDisabledExtensions = useExtensionsStore((state) => {
|
||||
return state.setDisabledExtensions;
|
||||
});
|
||||
|
||||
useMount(async () => {
|
||||
const result = await platformAdapter.invokeBackend<Extension[]>(
|
||||
"list_extensions",
|
||||
{
|
||||
listEnabled: false,
|
||||
}
|
||||
);
|
||||
|
||||
if (!isArray(result)) return;
|
||||
|
||||
const disabledExtensions = result.filter((item) => !item.enabled);
|
||||
|
||||
setDisabledExtensions(disabledExtensions.map((item) => item.id));
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{langUpdated && <Outlet />}
|
||||
<ErrorNotification />
|
||||
</>
|
||||
);
|
||||
}
|
||||
export default Layout;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user