mirror of
https://github.com/infinilabs/coco-app.git
synced 2025-12-18 12:37:45 +01:00
Compare commits
108 Commits
refactor-h
...
v0.9.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ac0065234 | ||
|
|
31806b6057 | ||
|
|
533bfaf45b | ||
|
|
459705af70 | ||
|
|
84e556ddad | ||
|
|
b50a20c7d4 | ||
|
|
d4ccd780b2 | ||
|
|
aef934e9a2 | ||
|
|
1fb927c26b | ||
|
|
8974624b3c | ||
|
|
d99b35bf4c | ||
|
|
594d0ffe3f | ||
|
|
7b08a87766 | ||
|
|
ab5ca24270 | ||
|
|
c593b07187 | ||
|
|
c088dde749 | ||
|
|
1fdf7c499d | ||
|
|
01dfc616d4 | ||
|
|
8d7d655581 | ||
|
|
5292538dd7 | ||
|
|
bab98d4576 | ||
|
|
6067fa7029 | ||
|
|
61860b400f | ||
|
|
50518b6c21 | ||
|
|
b5d3ce9910 | ||
|
|
abac92d8d5 | ||
|
|
9ea7c9b1ff | ||
|
|
fcbc77fb5a | ||
|
|
60b34a118b | ||
|
|
3e0839f3da | ||
|
|
bd61faf660 | ||
|
|
0e48f4f71c | ||
|
|
24fe7144f8 | ||
|
|
e92eee1ecf | ||
|
|
1996298f0c | ||
|
|
c879c63b17 | ||
|
|
892fe78d03 | ||
|
|
e5860f63c7 | ||
|
|
fa9656bfd7 | ||
|
|
03954748b6 | ||
|
|
4a627cb32e | ||
|
|
3029303e95 | ||
|
|
fc7cd165a8 | ||
|
|
f267df3f71 | ||
|
|
b07707e973 | ||
|
|
6b0111b89f | ||
|
|
e029ddf2ba | ||
|
|
731cfc5bd7 | ||
|
|
cbd8dc52cd | ||
|
|
d1ad1af71a | ||
|
|
121f9c6118 | ||
|
|
770f60f30c | ||
|
|
5c92b5acab | ||
|
|
8e49455acf | ||
|
|
859def21bf | ||
|
|
6145306ee8 | ||
|
|
d0f7b7b833 | ||
|
|
f221606ae2 | ||
|
|
cd00ada3ac | ||
|
|
be6611133a | ||
|
|
9e682ceafc | ||
|
|
5510bedf7f | ||
|
|
ea34b7a404 | ||
|
|
ce94543baa | ||
|
|
89a8304b9e | ||
|
|
9652a54f08 | ||
|
|
ca71f07f3a | ||
|
|
00eb6bed2b | ||
|
|
95dc7a88d2 | ||
|
|
6aec9cbae2 | ||
|
|
4e58bc4b2c | ||
|
|
a9a4b5319c | ||
|
|
6523fef12b | ||
|
|
b8affcd4a1 | ||
|
|
595ae676b7 | ||
|
|
5c76c92c95 | ||
|
|
f03ad8a6c8 | ||
|
|
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 |
36
.github/workflows/frontend-ci.yml
vendored
36
.github/workflows/frontend-ci.yml
vendored
@@ -5,6 +5,8 @@ on:
|
|||||||
# Only run it when Frontend code changes
|
# Only run it when Frontend code changes
|
||||||
paths:
|
paths:
|
||||||
- 'src/**'
|
- 'src/**'
|
||||||
|
- 'tsup.config.ts'
|
||||||
|
- 'package.json'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
check:
|
check:
|
||||||
@@ -17,6 +19,9 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ github.head_ref }}
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
@@ -30,5 +35,36 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Switch platformAdapter to Web adapter
|
||||||
|
shell: bash
|
||||||
|
run: >
|
||||||
|
node -e "const fs=require('fs');const f='src/utils/platformAdapter.ts';
|
||||||
|
let s=fs.readFileSync(f,'utf8');
|
||||||
|
s=s.replace(/import\\s*\\{\\s*createTauriAdapter\\s*\\}\\s*from\\s*\\\"\\.\\/tauriAdapter\\\";/,'import { createWebAdapter } from \\\"./webAdapter\\\";');
|
||||||
|
s=s.replace(/let\\s+platformAdapter\\s*=\\s*createTauriAdapter\\(\\);/,'let platformAdapter = createWebAdapter();');
|
||||||
|
fs.writeFileSync(f,s);"
|
||||||
|
|
||||||
|
- name: Build web (Tauri dependency check)
|
||||||
|
run: pnpm build:web
|
||||||
|
|
||||||
|
- name: Verify no Tauri refs in web output
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
if grep -R -n -E '@tauri-apps|tauri-plugin' out/search-chat; then
|
||||||
|
echo 'Tauri references found in web build output';
|
||||||
|
exit 1;
|
||||||
|
else
|
||||||
|
echo 'No Tauri references found';
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Restore platformAdapter to Tauri adapter
|
||||||
|
shell: bash
|
||||||
|
run: >
|
||||||
|
node -e "const fs=require('fs');const f='src/utils/platformAdapter.ts';
|
||||||
|
let s=fs.readFileSync(f,'utf8');
|
||||||
|
s=s.replace(/import\\s*\\{\\s*createWebAdapter\\s*\\}\\s*from\\s*\\\"\\.\\/webAdapter\\\";/,'import { createTauriAdapter } from \\\"./tauriAdapter\\\";');
|
||||||
|
s=s.replace(/let\\s+platformAdapter\\s*=\\s*createWebAdapter\\(\\);/,'let platformAdapter = createTauriAdapter();');
|
||||||
|
fs.writeFileSync(f,s);"
|
||||||
|
|
||||||
- name: Build frontend
|
- name: Build frontend
|
||||||
run: pnpm build
|
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')
|
if: startsWith(matrix.platform, 'ubuntu-22.04')
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf xdg-utils
|
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf xdg-utils libtracker-sparql-3.0-dev
|
||||||
|
|
||||||
|
|
||||||
|
# On Windows, we need to generate bindings for 'searchapi.h' using bindgen.
|
||||||
|
# And bindgen relies on 'libclang'
|
||||||
|
# https://rust-lang.github.io/rust-bindgen/requirements.html#windows
|
||||||
|
#
|
||||||
|
# We don't need to install it because it is already included in GitHub
|
||||||
|
# Action runner image:
|
||||||
|
# https://github.com/actions/runner-images/blob/main/images/windows/Windows2025-Readme.md#language-and-runtime
|
||||||
|
|
||||||
|
|
||||||
- name: Add Rust build target
|
- name: Add Rust build target
|
||||||
working-directory: src-tauri
|
working-directory: src-tauri
|
||||||
|
|||||||
10
.github/workflows/rust_code_check.yml
vendored
10
.github/workflows/rust_code_check.yml
vendored
@@ -30,7 +30,15 @@ jobs:
|
|||||||
if: startsWith(matrix.platform, 'ubuntu-latest')
|
if: startsWith(matrix.platform, 'ubuntu-latest')
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf xdg-utils
|
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf xdg-utils libtracker-sparql-3.0-dev
|
||||||
|
|
||||||
|
# On Windows, we need to generate bindings for 'searchapi.h' using bindgen.
|
||||||
|
# And bindgen relies on 'libclang'
|
||||||
|
# https://rust-lang.github.io/rust-bindgen/requirements.html#windows
|
||||||
|
#
|
||||||
|
# We don't need to install it because it is already included in GitHub
|
||||||
|
# Action runner image:
|
||||||
|
# https://github.com/actions/runner-images/blob/main/images/windows/Windows2025-Readme.md#language-and-runtime
|
||||||
|
|
||||||
- name: Add pizza engine as a dependency
|
- name: Add pizza engine as a dependency
|
||||||
working-directory: src-tauri
|
working-directory: src-tauri
|
||||||
|
|||||||
@@ -64,9 +64,9 @@ At Coco AI, we aim to streamline workplace collaboration by centralizing access
|
|||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- Node.js >= 18.12
|
- [Node.js >= 18.12](https://nodejs.org/en/download/)
|
||||||
- Rust (latest stable)
|
- [Rust (latest stable)](https://www.rust-lang.org/tools/install)
|
||||||
- pnpm (package manager)
|
- [pnpm (package manager)](https://pnpm.io/installation)
|
||||||
|
|
||||||
### Development Setup
|
### Development Setup
|
||||||
|
|
||||||
|
|||||||
22
components.json
Normal file
22
components.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "new-york",
|
||||||
|
"rsc": false,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "tailwind.config.js",
|
||||||
|
"css": "src/main.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide",
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"registries": {}
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ Coco AI is a fully open-source, cross-platform unified search and productivity t
|
|||||||
|
|
||||||
{{% load-img "/img/coco-preview.gif" "" %}}
|
{{% load-img "/img/coco-preview.gif" "" %}}
|
||||||
|
|
||||||
For more details on Coco Server, visit: [https://docs.infinilabs.com/coco-app/](https://docs.infinilabs.com/coco-app/).
|
For more details on Coco Server, visit: [https://docs.infinilabs.com/coco-server/](https://docs.infinilabs.com/coco-server/).
|
||||||
|
|
||||||
## Community
|
## Community
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,12 @@ asciinema: true
|
|||||||
[x11_protocol]: https://en.wikipedia.org/wiki/X_Window_System
|
[x11_protocol]: https://en.wikipedia.org/wiki/X_Window_System
|
||||||
[if_x11]: https://unix.stackexchange.com/q/202891/498440
|
[if_x11]: https://unix.stackexchange.com/q/202891/498440
|
||||||
|
|
||||||
|
## Install dependencies
|
||||||
|
|
||||||
|
```sh
|
||||||
|
$ sudo apt-get update
|
||||||
|
$ sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf xdg-utils libtracker-sparql-3.0-dev
|
||||||
|
```
|
||||||
|
|
||||||
## Go to the download page
|
## Go to the download page
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,72 @@ Information about release notes of Coco App is provided here.
|
|||||||
|
|
||||||
### 🚀 Features
|
### 🚀 Features
|
||||||
|
|
||||||
|
### 🐛 Bug fix
|
||||||
|
|
||||||
|
- fix: search_extension should not panic when ext is not found #983
|
||||||
|
- fix: persist configuration settings properly #987
|
||||||
|
|
||||||
|
### ✈️ Improvements
|
||||||
|
|
||||||
|
## 0.9.0 (2025-11-19)
|
||||||
|
|
||||||
|
### ❌ Breaking changes
|
||||||
|
|
||||||
|
### 🚀 Features
|
||||||
|
|
||||||
|
- feat: support switching groups via keyboard shortcuts #911
|
||||||
|
- feat: support opening logs from about page #915
|
||||||
|
- feat: support moving cursor with home and end keys #918
|
||||||
|
- feat: support pageup/pagedown to navigate search results #920
|
||||||
|
- feat: standardize multi-level menu label structure #925
|
||||||
|
- feat(View Extension): page field now accepts HTTP(s) links #925
|
||||||
|
- feat: return sub-exts when extension type exts themselves are matched #928
|
||||||
|
- feat: open quick ai with modifier key + enter #939
|
||||||
|
- feat: allow navigate back when cursor is at the beginning #940
|
||||||
|
- feat(extension compatibility): minimum_coco_version #946
|
||||||
|
- feat: add compact mode for window #947
|
||||||
|
- feat: advanced settings search debounce & local query source weight #950
|
||||||
|
- feat: add window opacity configuration option #963
|
||||||
|
- feat: add auto collapse delay for compact mode #981
|
||||||
|
|
||||||
|
### 🐛 Bug fix
|
||||||
|
|
||||||
|
- fix: automatic update of service list #913
|
||||||
|
- fix: duplicate chat content #916
|
||||||
|
- fix: resolve pinned window shortcut not working #917
|
||||||
|
- fix: WM ext does not work when operating focused win from another display #919
|
||||||
|
- fix(Window Management): Next/Previous Desktop do not work #926
|
||||||
|
- fix: fix page rapidly flickering issue #935
|
||||||
|
- fix(view extension): broken search bar UI when opening extensions via hotkey #938
|
||||||
|
- fix: allow deletion after selecting all text #943
|
||||||
|
- fix: prevent shaking when switching between chat and search pages #955
|
||||||
|
- fix: prevent duplicate login success messages #977
|
||||||
|
- fix: fix quick ai not continuing conversation #979
|
||||||
|
|
||||||
|
### ✈️ Improvements
|
||||||
|
|
||||||
|
- refactor: improve sorting logic of search results #910
|
||||||
|
- style: add dark drop shadow to images #912
|
||||||
|
- chore: add cross-domain configuration for web component #921
|
||||||
|
- refactor: retry if AXUIElementSetAttributeValue() does not work #924
|
||||||
|
- refactor(calculator): skip evaluation if expr is in form "num => num" #929
|
||||||
|
- chore: use a custom log directory #930
|
||||||
|
- chore: bump tauri_nspanel to v2.1 #933
|
||||||
|
- refactor: show_coco/hide_coco now use NSPanel's function on macOS #933
|
||||||
|
- refactor: procedure that convert_pages() into a func #934
|
||||||
|
- refactor(post-search): collect at least 2 documents from each query source #948
|
||||||
|
- refactor: custom_version_comparator() now compares semantic versions #941
|
||||||
|
- chore: center the main window vertically #959
|
||||||
|
- refactor(view extension): load HTML/resources via local HTTP server #973
|
||||||
|
|
||||||
|
## 0.8.0 (2025-09-28)
|
||||||
|
|
||||||
|
### ❌ Breaking changes
|
||||||
|
|
||||||
|
- chore: update request accesstoken api #866
|
||||||
|
|
||||||
|
### 🚀 Features
|
||||||
|
|
||||||
- feat: enhance ui for skipped version #834
|
- feat: enhance ui for skipped version #834
|
||||||
- feat: support installing local extensions #749
|
- feat: support installing local extensions #749
|
||||||
- feat: support sending files in chat messages #764
|
- feat: support sending files in chat messages #764
|
||||||
@@ -20,11 +86,24 @@ Information about release notes of Coco App is provided here.
|
|||||||
- feat: add extension uninstall option in settings #855
|
- feat: add extension uninstall option in settings #855
|
||||||
- feat: impl extension settings 'hide_before_open' #862
|
- feat: impl extension settings 'hide_before_open' #862
|
||||||
- feat: index both en/zh_CN app names and show app name in chosen language #875
|
- feat: index both en/zh_CN app names and show app name in chosen language #875
|
||||||
|
- feat: support context menu in debug mode #882
|
||||||
|
- feat: file search for Linux/GNOME #884
|
||||||
|
- feat: file search for Linux/KDE #886
|
||||||
|
- feat: extension Window Management for macOS #892
|
||||||
|
- feat: new extension type View #894
|
||||||
|
- feat: support opening file in its containing folder #900
|
||||||
|
|
||||||
### 🐛 Bug fix
|
### 🐛 Bug fix
|
||||||
|
|
||||||
- fix: fix issue with update check failure #833
|
- fix: fix issue with update check failure #833
|
||||||
- fix: web component login state #857
|
- fix: web component login state #857
|
||||||
|
- fix: shortcut key not opening extension store #877
|
||||||
|
- fix: set up hotkey on main thread or Windows will complain #879
|
||||||
|
- fix: resolve deeplink login issue #881
|
||||||
|
- fix: use kill_on_drop() to avoid zombie proc in error case #887
|
||||||
|
- fix: settings window rendering/loading issue 889
|
||||||
|
- fix: ensure search paths are indexed #896
|
||||||
|
- fix: bump applications-rs to fix empty app name issue #898
|
||||||
|
|
||||||
### ✈️ Improvements
|
### ✈️ Improvements
|
||||||
|
|
||||||
@@ -38,7 +117,11 @@ Information about release notes of Coco App is provided here.
|
|||||||
- build: web component build error #858
|
- build: web component build error #858
|
||||||
- refactor: coordinate third-party extension operations using lock #867
|
- refactor: coordinate third-party extension operations using lock #867
|
||||||
- refactor: index iOS apps and macOS apps that store icon in Assets.car #872
|
- refactor: index iOS apps and macOS apps that store icon in Assets.car #872
|
||||||
- refactor: accept both '-' and '_' as locale str separator #876
|
- refactor: accept both '-' and '\_' as locale str separator #876
|
||||||
|
- refactor: relax the file search conditions on macOS #883
|
||||||
|
- refactor: ensure Coco won't take focus #891
|
||||||
|
- chore: skip login check for web widget #895
|
||||||
|
- chore: convertFileSrc() "link[href]" and "img[src]" #901
|
||||||
|
|
||||||
## 0.7.1 (2025-07-27)
|
## 0.7.1 (2025-07-27)
|
||||||
|
|
||||||
|
|||||||
11
package.json
11
package.json
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "coco",
|
"name": "coco",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.7.1",
|
"version": "0.9.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@headlessui/react": "^2.2.2",
|
"@headlessui/react": "^2.2.2",
|
||||||
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
"@tauri-apps/api": "^2.5.0",
|
"@tauri-apps/api": "^2.5.0",
|
||||||
"@tauri-apps/plugin-autostart": "~2.2.0",
|
"@tauri-apps/plugin-autostart": "~2.2.0",
|
||||||
"@tauri-apps/plugin-deep-link": "^2.2.1",
|
"@tauri-apps/plugin-deep-link": "^2.2.1",
|
||||||
@@ -26,7 +27,7 @@
|
|||||||
"@tauri-apps/plugin-global-shortcut": "~2.0.0",
|
"@tauri-apps/plugin-global-shortcut": "~2.0.0",
|
||||||
"@tauri-apps/plugin-http": "~2.0.2",
|
"@tauri-apps/plugin-http": "~2.0.2",
|
||||||
"@tauri-apps/plugin-log": "~2.4.0",
|
"@tauri-apps/plugin-log": "~2.4.0",
|
||||||
"@tauri-apps/plugin-opener": "^2.2.7",
|
"@tauri-apps/plugin-opener": "^2.5.0",
|
||||||
"@tauri-apps/plugin-os": "^2.2.1",
|
"@tauri-apps/plugin-os": "^2.2.1",
|
||||||
"@tauri-apps/plugin-process": "^2.2.1",
|
"@tauri-apps/plugin-process": "^2.2.1",
|
||||||
"@tauri-apps/plugin-shell": "^2.2.1",
|
"@tauri-apps/plugin-shell": "^2.2.1",
|
||||||
@@ -34,7 +35,8 @@
|
|||||||
"@tauri-apps/plugin-window": "2.0.0-alpha.1",
|
"@tauri-apps/plugin-window": "2.0.0-alpha.1",
|
||||||
"@wavesurfer/react": "^1.0.11",
|
"@wavesurfer/react": "^1.0.11",
|
||||||
"ahooks": "^3.8.4",
|
"ahooks": "^3.8.4",
|
||||||
"axios": "^1.9.0",
|
"axios": "^1.12.0",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"dotenv": "^16.5.0",
|
"dotenv": "^16.5.0",
|
||||||
@@ -59,6 +61,7 @@
|
|||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"remark-math": "^6.0.0",
|
"remark-math": "^6.0.0",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"tauri-plugin-fs-pro-api": "^2.4.0",
|
"tauri-plugin-fs-pro-api": "^2.4.0",
|
||||||
"tauri-plugin-macos-permissions-api": "^2.3.0",
|
"tauri-plugin-macos-permissions-api": "^2.3.0",
|
||||||
"tauri-plugin-screenshots-api": "^2.2.0",
|
"tauri-plugin-screenshots-api": "^2.2.0",
|
||||||
@@ -93,4 +96,4 @@
|
|||||||
"vite": "^5.4.19"
|
"vite": "^5.4.19"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.11.0+sha512.6540583f41cc5f628eb3d9773ecee802f4f9ef9923cc45b69890fb47991d4b092964694ec3a4f738a420c918a333062c8b925d312f42e4f0c263eb603551f977"
|
"packageManager": "pnpm@10.11.0+sha512.6540583f41cc5f628eb3d9773ecee802f4f9ef9923cc45b69890fb47991d4b092964694ec3a4f738a420c918a333062c8b925d312f42e4f0c263eb603551f977"
|
||||||
}
|
}
|
||||||
|
|||||||
1102
pnpm-lock.yaml
generated
1102
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
3137
src-tauri/Cargo.lock
generated
3137
src-tauri/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "coco"
|
name = "coco"
|
||||||
version = "0.7.1"
|
version = "0.9.0"
|
||||||
description = "Search, connect, collaborate – all in one place."
|
description = "Search, connect, collaborate – all in one place."
|
||||||
authors = ["INFINI Labs"]
|
authors = ["INFINI Labs"]
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
@@ -15,6 +15,7 @@ crate-type = ["staticlib", "cdylib", "rlib"]
|
|||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
tauri-build = { version = "2", features = ["default"] }
|
tauri-build = { version = "2", features = ["default"] }
|
||||||
|
cfg-if = "1.0.1"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["desktop"]
|
default = ["desktop"]
|
||||||
@@ -61,7 +62,7 @@ tauri-plugin-drag = "2"
|
|||||||
tauri-plugin-macos-permissions = "2"
|
tauri-plugin-macos-permissions = "2"
|
||||||
tauri-plugin-fs-pro = "2"
|
tauri-plugin-fs-pro = "2"
|
||||||
tauri-plugin-screenshots = "2"
|
tauri-plugin-screenshots = "2"
|
||||||
applications = { git = "https://github.com/infinilabs/applications-rs", rev = "2f1f88d1880404c5f8d70ad950b859bd49922bee" }
|
applications = { git = "https://github.com/infinilabs/applications-rs", rev = "b5fac4034a40d42e72f727f1aa1cc1f19fe86653" }
|
||||||
tokio-native-tls = "0.3" # For wss connections
|
tokio-native-tls = "0.3" # For wss connections
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
tokio-tungstenite = { version = "0.20", features = ["native-tls"] }
|
tokio-tungstenite = { version = "0.20", features = ["native-tls"] }
|
||||||
@@ -103,17 +104,44 @@ zip = "4.0.0"
|
|||||||
url = "2.5.2"
|
url = "2.5.2"
|
||||||
camino = "1.1.10"
|
camino = "1.1.10"
|
||||||
tokio-stream = { version = "0.1.17", features = ["io-util"] }
|
tokio-stream = { version = "0.1.17", features = ["io-util"] }
|
||||||
cfg-if = "1.0.1"
|
|
||||||
sysinfo = "0.35.2"
|
sysinfo = "0.35.2"
|
||||||
indexmap = { version = "2.10.0", features = ["serde"] }
|
indexmap = { version = "2.10.0", features = ["serde"] }
|
||||||
strum = { version = "0.27.2", features = ["derive"] }
|
strum = { version = "0.27.2", features = ["derive"] }
|
||||||
sys-locale = "0.3.2"
|
sys-locale = "0.3.2"
|
||||||
|
tauri-plugin-prevent-default = "1"
|
||||||
|
oneshot = "0.1.11"
|
||||||
|
bitflags = "2.9.3"
|
||||||
|
cfg-if = "1.0.1"
|
||||||
|
dunce = "1.0.5"
|
||||||
|
urlencoding = "2.1.3"
|
||||||
|
scraper = "0.17"
|
||||||
|
toml = "0.8"
|
||||||
|
path-clean = "1.0.1"
|
||||||
|
actix-files = "0.6.8"
|
||||||
|
actix-web = "4.11.0"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = "3.23.0"
|
||||||
|
|
||||||
[target."cfg(target_os = \"macos\")".dependencies]
|
[target."cfg(target_os = \"macos\")".dependencies]
|
||||||
tauri-nspanel = { git = "https://github.com/ahkohd/tauri-nspanel", branch = "v2" }
|
tauri-nspanel = { git = "https://github.com/ahkohd/tauri-nspanel", branch = "v2.1" }
|
||||||
|
objc2-app-kit = { version = "0.3.1", features = ["NSWindow"] }
|
||||||
|
objc2 = "0.6.2"
|
||||||
|
objc2-core-foundation = {version = "0.3.1", features = ["CFString", "CFCGTypes", "CFArray"] }
|
||||||
|
objc2-application-services = { version = "0.3.1", features = ["HIServices"] }
|
||||||
|
objc2-core-graphics = { version = "=0.3.1", features = ["CGEvent"] }
|
||||||
|
|
||||||
|
[target."cfg(target_os = \"linux\")".dependencies]
|
||||||
|
gio = "0.21.2"
|
||||||
|
glib = "0.21.2"
|
||||||
|
tracker-rs = "0.7"
|
||||||
|
which = "8.0.0"
|
||||||
|
configparser = "3.1.0"
|
||||||
|
|
||||||
[target."cfg(any(target_os = \"macos\", windows, target_os = \"linux\"))".dependencies]
|
[target."cfg(any(target_os = \"macos\", windows, target_os = \"linux\"))".dependencies]
|
||||||
tauri-plugin-single-instance = { version = "2.0.0", features = ["deep-link"] }
|
tauri-plugin-single-instance = { version = "2.0.0", features = ["deep-link"] }
|
||||||
|
serde = { version = "1.0.219", features = ["derive"], optional = true }
|
||||||
|
|
||||||
|
|
||||||
[profile.dev]
|
[profile.dev]
|
||||||
incremental = true # Compile your binary in smaller steps.
|
incremental = true # Compile your binary in smaller steps.
|
||||||
@@ -134,4 +162,8 @@ semver = { version = "1", features = ["serde"] }
|
|||||||
|
|
||||||
[target."cfg(target_os = \"windows\")".dependencies]
|
[target."cfg(target_os = \"windows\")".dependencies]
|
||||||
enigo="0.3"
|
enigo="0.3"
|
||||||
windows = { version = "0.61.3", features = ["Win32_Foundation", "Win32_System_Com", "Win32_System_Ole", "Win32_System_Search", "Win32_UI_Shell_PropertiesSystem", "Win32_Data"] }
|
windows = { version = "0.61", features = ["Win32_Foundation", "Win32_System_Com", "Win32_System_Ole", "Win32_System_Search", "Win32_UI_Shell_PropertiesSystem", "Win32_Data"] }
|
||||||
|
windows-sys = { version = "0.61", features = ["Win32", "Win32_System", "Win32_System_Com"] }
|
||||||
|
|
||||||
|
[target."cfg(target_os = \"windows\")".build-dependencies]
|
||||||
|
bindgen = "0.72.1"
|
||||||
|
|||||||
@@ -11,4 +11,32 @@ fn main() {
|
|||||||
//
|
//
|
||||||
// unexpected condition name: `ci`
|
// unexpected condition name: `ci`
|
||||||
println!("cargo::rustc-check-cfg=cfg(ci)");
|
println!("cargo::rustc-check-cfg=cfg(ci)");
|
||||||
|
|
||||||
|
// Bindgen searchapi.h on Windows as the windows create does not provide
|
||||||
|
// bindings for it
|
||||||
|
cfg_if::cfg_if! {
|
||||||
|
if #[cfg(target_os = "windows")] {
|
||||||
|
use std::env;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
let wrapper_header = r#"#include <windows.h>
|
||||||
|
#include <searchapi.h>"#;
|
||||||
|
|
||||||
|
let searchapi_bindings = bindgen::Builder::default()
|
||||||
|
.header_contents("wrapper.h", wrapper_header)
|
||||||
|
.generate()
|
||||||
|
.expect("failed to generate bindings for <searchapi.h>");
|
||||||
|
|
||||||
|
let out_path = PathBuf::from(env::var("OUT_DIR").unwrap());
|
||||||
|
searchapi_bindings
|
||||||
|
.write_to_file(out_path.join("searchapi_bindings.rs"))
|
||||||
|
.expect("couldn't write bindings to <OUT_DIR/searchapi_bindings.rs>")
|
||||||
|
|
||||||
|
// Looks like there is no need to link the library that contains the
|
||||||
|
// implementation of functions declared in 'searchapi.h' manually as
|
||||||
|
// the FFI bindings work (without doing that).
|
||||||
|
//
|
||||||
|
// This is wield, I do not expect the linker will link it automatically.
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
use crate::extension::ExtensionSettings;
|
#[cfg(target_os = "macos")]
|
||||||
|
use crate::extension::built_in::window_management::actions::Action;
|
||||||
|
use crate::extension::view_extension::serve_files_in;
|
||||||
|
use crate::extension::{ExtensionPermission, ExtensionSettings, ViewExtensionUISettings};
|
||||||
|
use log::debug;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value as Json;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use tauri::AppHandle;
|
use tauri::{AppHandle, Emitter};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct RichLabel {
|
pub struct RichLabel {
|
||||||
@@ -43,6 +48,9 @@ pub(crate) enum OnOpened {
|
|||||||
Application { app_path: String },
|
Application { app_path: String },
|
||||||
/// Open the URL.
|
/// Open the URL.
|
||||||
Document { url: String },
|
Document { url: String },
|
||||||
|
/// Perform this WM action.
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
WindowManagementAction { action: Action },
|
||||||
/// The document is an extension.
|
/// The document is an extension.
|
||||||
Extension(ExtensionOnOpened),
|
Extension(ExtensionOnOpened),
|
||||||
}
|
}
|
||||||
@@ -55,6 +63,11 @@ pub(crate) struct ExtensionOnOpened {
|
|||||||
///
|
///
|
||||||
/// Optional because not all extensions have their settings.
|
/// Optional because not all extensions have their settings.
|
||||||
pub(crate) settings: Option<ExtensionSettings>,
|
pub(crate) settings: Option<ExtensionSettings>,
|
||||||
|
/// Permission needed by this extension.
|
||||||
|
///
|
||||||
|
/// We do permission check when opening this permission. Currently, we only
|
||||||
|
/// do this to View extensions.
|
||||||
|
pub(crate) permission: Option<ExtensionPermission>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -74,6 +87,17 @@ pub(crate) enum ExtensionOnOpenedType {
|
|||||||
link: crate::extension::QuicklinkLink,
|
link: crate::extension::QuicklinkLink,
|
||||||
open_with: Option<String>,
|
open_with: Option<String>,
|
||||||
},
|
},
|
||||||
|
View {
|
||||||
|
/// Extension name
|
||||||
|
name: String,
|
||||||
|
// An absolute path to the extension icon or a font code.
|
||||||
|
icon: String,
|
||||||
|
/// Path to the HTML file that coco will load and render.
|
||||||
|
///
|
||||||
|
/// It should be an absolute path or Tauri cannot open it.
|
||||||
|
page: String,
|
||||||
|
ui: Option<ViewExtensionUISettings>,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
impl OnOpened {
|
impl OnOpened {
|
||||||
@@ -81,6 +105,11 @@ impl OnOpened {
|
|||||||
match self {
|
match self {
|
||||||
Self::Application { app_path } => app_path.clone(),
|
Self::Application { app_path } => app_path.clone(),
|
||||||
Self::Document { url } => url.clone(),
|
Self::Document { url } => url.clone(),
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
Self::WindowManagementAction { action: _ } => {
|
||||||
|
// We don't have URL for this
|
||||||
|
String::from("N/A")
|
||||||
|
}
|
||||||
Self::Extension(ext_on_opened) => {
|
Self::Extension(ext_on_opened) => {
|
||||||
match &ext_on_opened.ty {
|
match &ext_on_opened.ty {
|
||||||
ExtensionOnOpenedType::Command { action } => {
|
ExtensionOnOpenedType::Command { action } => {
|
||||||
@@ -97,6 +126,15 @@ impl OnOpened {
|
|||||||
// The URL of a quicklink is nearly useless without such dynamic user
|
// The URL of a quicklink is nearly useless without such dynamic user
|
||||||
// inputs, so until we have dynamic URL support, we just use "N/A".
|
// inputs, so until we have dynamic URL support, we just use "N/A".
|
||||||
ExtensionOnOpenedType::Quicklink { .. } => String::from("N/A"),
|
ExtensionOnOpenedType::Quicklink { .. } => String::from("N/A"),
|
||||||
|
ExtensionOnOpenedType::View {
|
||||||
|
name: _,
|
||||||
|
icon: _,
|
||||||
|
page: _,
|
||||||
|
ui: _,
|
||||||
|
} => {
|
||||||
|
// We currently don't have URL for this kind of extension.
|
||||||
|
String::from("N/A")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -107,7 +145,7 @@ impl OnOpened {
|
|||||||
pub(crate) async fn open(
|
pub(crate) async fn open(
|
||||||
tauri_app_handle: AppHandle,
|
tauri_app_handle: AppHandle,
|
||||||
on_opened: OnOpened,
|
on_opened: OnOpened,
|
||||||
extra_args: Option<HashMap<String, String>>,
|
extra_args: Option<HashMap<String, Json>>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
use crate::util::open as homemade_tauri_shell_open;
|
use crate::util::open as homemade_tauri_shell_open;
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
@@ -123,6 +161,15 @@ pub(crate) async fn open(
|
|||||||
|
|
||||||
homemade_tauri_shell_open(tauri_app_handle.clone(), url).await?
|
homemade_tauri_shell_open(tauri_app_handle.clone(), url).await?
|
||||||
}
|
}
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
OnOpened::WindowManagementAction { action } => {
|
||||||
|
log::debug!("perform Window Management action [{:?}]", action);
|
||||||
|
|
||||||
|
crate::extension::built_in::window_management::perform_action_on_main_thread(
|
||||||
|
&tauri_app_handle,
|
||||||
|
action,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
OnOpened::Extension(ext_on_opened) => {
|
OnOpened::Extension(ext_on_opened) => {
|
||||||
// Apply the settings that would affect open behavior
|
// Apply the settings that would affect open behavior
|
||||||
if let Some(settings) = ext_on_opened.settings {
|
if let Some(settings) = ext_on_opened.settings {
|
||||||
@@ -132,6 +179,7 @@ pub(crate) async fn open(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let permission = ext_on_opened.permission;
|
||||||
|
|
||||||
match ext_on_opened.ty {
|
match ext_on_opened.ty {
|
||||||
ExtensionOnOpenedType::Command { action } => {
|
ExtensionOnOpenedType::Command { action } => {
|
||||||
@@ -196,6 +244,51 @@ pub(crate) async fn open(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
ExtensionOnOpenedType::View {
|
||||||
|
name,
|
||||||
|
icon,
|
||||||
|
page,
|
||||||
|
ui,
|
||||||
|
} => {
|
||||||
|
let page_path = Utf8Path::new(&page);
|
||||||
|
let directory = page_path.parent().unwrap_or_else(|| {
|
||||||
|
panic!("View extension page path should have a parent, i.e., it should be under a directory, but [{}] does not", page);
|
||||||
|
});
|
||||||
|
let mut url = serve_files_in(directory.as_ref()).await;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Emit an event to let the frontend code open this extension.
|
||||||
|
*
|
||||||
|
* Payload `view_extension_opened` contains the information needed
|
||||||
|
* to do that.
|
||||||
|
*
|
||||||
|
* See "src/pages/main/index.tsx" for more info.
|
||||||
|
*/
|
||||||
|
use camino::Utf8Path;
|
||||||
|
use serde_json::Value as Json;
|
||||||
|
use serde_json::to_value;
|
||||||
|
|
||||||
|
let html_filename = page_path
|
||||||
|
.file_name()
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
panic!("View extension page path should have a file name, but [{}] does not have one", page);
|
||||||
|
}).to_string();
|
||||||
|
url.push('/');
|
||||||
|
url.push_str(&html_filename);
|
||||||
|
|
||||||
|
let html_file_url = url;
|
||||||
|
debug!("View extension listening on: {}", html_file_url);
|
||||||
|
let view_extension_opened: [Json; 5] = [
|
||||||
|
Json::String(name),
|
||||||
|
Json::String(icon),
|
||||||
|
Json::String(html_file_url),
|
||||||
|
to_value(permission).unwrap(),
|
||||||
|
to_value(ui).unwrap(),
|
||||||
|
];
|
||||||
|
tauri_app_handle
|
||||||
|
.emit("open_view_extension", view_extension_opened)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ impl SearchQuery {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize, Hash, PartialEq, Eq)]
|
||||||
pub struct QuerySource {
|
pub struct QuerySource {
|
||||||
pub r#type: String, //coco-server/local/ etc.
|
pub r#type: String, //coco-server/local/ etc.
|
||||||
pub id: String, //coco server's id
|
pub id: String, //coco server's id
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
@@ -1227,6 +1227,7 @@ pub async fn get_app_list(tauri_app_handle: AppHandle) -> Result<Vec<Extension>,
|
|||||||
name,
|
name,
|
||||||
platforms: None,
|
platforms: None,
|
||||||
developer: None,
|
developer: None,
|
||||||
|
minimum_coco_version: None,
|
||||||
// Leave it empty as it won't be used
|
// Leave it empty as it won't be used
|
||||||
description: String::new(),
|
description: String::new(),
|
||||||
icon: icon_path,
|
icon: icon_path,
|
||||||
@@ -1235,11 +1236,15 @@ pub async fn get_app_list(tauri_app_handle: AppHandle) -> Result<Vec<Extension>,
|
|||||||
quicklink: None,
|
quicklink: None,
|
||||||
commands: None,
|
commands: None,
|
||||||
scripts: None,
|
scripts: None,
|
||||||
|
views: None,
|
||||||
quicklinks: None,
|
quicklinks: None,
|
||||||
alias: Some(alias),
|
alias: Some(alias),
|
||||||
hotkey,
|
hotkey,
|
||||||
enabled,
|
enabled,
|
||||||
settings: None,
|
settings: None,
|
||||||
|
page: None,
|
||||||
|
ui: None,
|
||||||
|
permission: None,
|
||||||
screenshots: None,
|
screenshots: None,
|
||||||
url: None,
|
url: None,
|
||||||
version: None,
|
version: None,
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ impl SearchSource for CalculatorSource {
|
|||||||
// will only be evaluated against non-whitespace characters.
|
// will only be evaluated against non-whitespace characters.
|
||||||
let query_string = query_string.trim();
|
let query_string = query_string.trim();
|
||||||
|
|
||||||
if query_string.is_empty() || query_string.len() == 1 {
|
if query_string.is_empty() {
|
||||||
return Ok(QueryResponse {
|
return Ok(QueryResponse {
|
||||||
source: self.get_type(),
|
source: self.get_type(),
|
||||||
hits: Vec::new(),
|
hits: Vec::new(),
|
||||||
@@ -150,6 +150,26 @@ impl SearchSource for CalculatorSource {
|
|||||||
let query_source = self.get_type();
|
let query_source = self.get_type();
|
||||||
let base_score = self.base_score;
|
let base_score = self.base_score;
|
||||||
let closure = move || -> QueryResponse {
|
let closure = move || -> QueryResponse {
|
||||||
|
let Ok(tokens) = meval::tokenizer::tokenize(&query_string_clone) else {
|
||||||
|
// Invalid expression, return nothing.
|
||||||
|
return QueryResponse {
|
||||||
|
source: query_source,
|
||||||
|
hits: Vec::new(),
|
||||||
|
total_hits: 0,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
// If it is only a number, no need to evaluate it as the result is
|
||||||
|
// this number.
|
||||||
|
// Actually, there is no need to return the result back to the users
|
||||||
|
// in such case because letting them know "x = x" is meaningless.
|
||||||
|
if tokens.len() == 1 && matches!(tokens[0], meval::tokenizer::Token::Number(_)) {
|
||||||
|
return QueryResponse {
|
||||||
|
source: query_source,
|
||||||
|
hits: Vec::new(),
|
||||||
|
total_hits: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
let res_num = meval::eval_str(&query_string_clone);
|
let res_num = meval::eval_str(&query_string_clone);
|
||||||
|
|
||||||
match res_num {
|
match res_num {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
//! File Search configuration entries definition and getter/setter functions.
|
//! File Search configuration entries definition and getter/setter functions.
|
||||||
|
|
||||||
|
use crate::extension::built_in::file_search::implementation::apply_config;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
@@ -23,7 +24,7 @@ static HOME_DIR: LazyLock<String> = LazyLock::new(|| {
|
|||||||
.expect("User home directory should be encoded with UTF-8")
|
.expect("User home directory should be encoded with UTF-8")
|
||||||
});
|
});
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Copy)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Copy, PartialEq)]
|
||||||
pub enum SearchBy {
|
pub enum SearchBy {
|
||||||
Name,
|
Name,
|
||||||
NameAndContents,
|
NameAndContents,
|
||||||
@@ -197,13 +198,19 @@ pub async fn set_file_system_config(
|
|||||||
.store(TAURI_STORE_FILE_SYSTEM_CONFIG)
|
.store(TAURI_STORE_FILE_SYSTEM_CONFIG)
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
store.set(TAURI_STORE_KEY_SEARCH_PATHS, config.search_paths);
|
store.set(TAURI_STORE_KEY_SEARCH_PATHS, config.search_paths.as_slice());
|
||||||
store.set(TAURI_STORE_KEY_EXCLUDE_PATHS, config.exclude_paths);
|
store.set(
|
||||||
store.set(TAURI_STORE_KEY_FILE_TYPES, config.file_types);
|
TAURI_STORE_KEY_EXCLUDE_PATHS,
|
||||||
|
config.exclude_paths.as_slice(),
|
||||||
|
);
|
||||||
|
store.set(TAURI_STORE_KEY_FILE_TYPES, config.file_types.as_slice());
|
||||||
store.set(
|
store.set(
|
||||||
TAURI_STORE_KEY_SEARCH_BY,
|
TAURI_STORE_KEY_SEARCH_BY,
|
||||||
serde_json::to_value(config.search_by).unwrap(),
|
serde_json::to_value(config.search_by).unwrap(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Apply the config when we know that this set operation won't fail
|
||||||
|
apply_config(&config)?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,388 @@
|
|||||||
|
//! File system powered by GNOME's Tracker engine.
|
||||||
|
|
||||||
|
use super::super::super::EXTENSION_ID;
|
||||||
|
use super::super::super::config::FileSearchConfig;
|
||||||
|
use super::super::should_be_filtered_out;
|
||||||
|
use crate::common::document::DataSourceReference;
|
||||||
|
use crate::extension::LOCAL_QUERY_SOURCE_TYPE;
|
||||||
|
use crate::util::file::sync_get_file_icon;
|
||||||
|
use crate::{
|
||||||
|
common::document::{Document, OnOpened},
|
||||||
|
extension::built_in::file_search::config::SearchBy,
|
||||||
|
};
|
||||||
|
use camino::Utf8Path;
|
||||||
|
use gio::Cancellable;
|
||||||
|
use gio::Settings;
|
||||||
|
use gio::prelude::SettingsExtManual;
|
||||||
|
use glib::GString;
|
||||||
|
use glib::collections::strv::StrV;
|
||||||
|
use tracker::{SparqlConnection, SparqlCursor, prelude::SparqlCursorExtManual};
|
||||||
|
|
||||||
|
/// The service that we will connect to.
|
||||||
|
const SERVICE_NAME: &str = "org.freedesktop.Tracker3.Miner.Files";
|
||||||
|
|
||||||
|
/// Tracker won't return scores when we are not using full-text seach. In that
|
||||||
|
/// case, we use this score.
|
||||||
|
const SCORE: f64 = 1.0;
|
||||||
|
|
||||||
|
/// Helper function to return different SPARQL queries depending on the different configurations.
|
||||||
|
fn query_sparql(query_string: &str, config: &FileSearchConfig) -> String {
|
||||||
|
match config.search_by {
|
||||||
|
SearchBy::Name => {
|
||||||
|
// Cannot use the inverted index as that searches for all the attributes,
|
||||||
|
// but we only want to search the filename.
|
||||||
|
format!(
|
||||||
|
"SELECT nie:url(?file_item) WHERE {{ ?file_item nfo:fileName ?fileName . FILTER(regex(?fileName, '{query_string}', 'i')) }}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
SearchBy::NameAndContents => {
|
||||||
|
// Full-text search against all attributes
|
||||||
|
// OR
|
||||||
|
// filename search
|
||||||
|
format!(
|
||||||
|
"SELECT nie:url(?file_item) fts:rank(?file_item) WHERE {{ {{ ?file_item fts:match '{query_string}' }} UNION {{ ?file_item nfo:fileName ?fileName . FILTER(regex(?fileName, '{query_string}', 'i')) }} }} ORDER BY DESC fts:rank(?file_item)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper function to replace unsupported characters with whitespace.
|
||||||
|
///
|
||||||
|
/// Tracker will error out if it encounters these characters.
|
||||||
|
///
|
||||||
|
/// The complete list of unsupported characters is unknown and we don't know how
|
||||||
|
/// to escape them, so let's replace them.
|
||||||
|
fn query_string_cleanup(old: &str) -> String {
|
||||||
|
const UNSUPPORTED_CHAR: [char; 3] = ['\'', '\n', '\\'];
|
||||||
|
|
||||||
|
// Using len in bytes is ok
|
||||||
|
let mut chars = Vec::with_capacity(old.len());
|
||||||
|
for char in old.chars() {
|
||||||
|
if UNSUPPORTED_CHAR.contains(&char) {
|
||||||
|
chars.push(' ');
|
||||||
|
} else {
|
||||||
|
chars.push(char);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
chars.into_iter().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Query {
|
||||||
|
conn: SparqlConnection,
|
||||||
|
cursor: SparqlCursor,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Query {
|
||||||
|
fn new(query_string: &str, config: &FileSearchConfig) -> Result<Self, String> {
|
||||||
|
let query_string = query_string_cleanup(query_string);
|
||||||
|
let sparql = query_sparql(&query_string, config);
|
||||||
|
let conn =
|
||||||
|
SparqlConnection::bus_new(SERVICE_NAME, None, None).map_err(|e| e.to_string())?;
|
||||||
|
let cursor = conn
|
||||||
|
.query(&sparql, Cancellable::NONE)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
Ok(Self { conn, cursor })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for Query {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.cursor.close();
|
||||||
|
self.conn.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Iterator for Query {
|
||||||
|
/// It yields a tuple `(file path, score)`
|
||||||
|
type Item = Result<(String, f64), String>;
|
||||||
|
|
||||||
|
fn next(&mut self) -> Option<Self::Item> {
|
||||||
|
loop {
|
||||||
|
let has_next = match self
|
||||||
|
.cursor
|
||||||
|
.next(Cancellable::NONE)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
{
|
||||||
|
Ok(has_next) => has_next,
|
||||||
|
Err(err_str) => return Some(Err(err_str)),
|
||||||
|
};
|
||||||
|
|
||||||
|
if !has_next {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The first column is the URL
|
||||||
|
let file_url_column = self.cursor.string(0);
|
||||||
|
// It could be None (or NULL ptr if you use C), I have no clue why.
|
||||||
|
let opt_str = file_url_column.as_ref().map(|gstr| gstr.as_str());
|
||||||
|
|
||||||
|
match opt_str {
|
||||||
|
Some(url) => {
|
||||||
|
// The returned URL has a prefix that we need to trim
|
||||||
|
const PREFIX: &str = "file://";
|
||||||
|
const PREFIX_LEN: usize = PREFIX.len();
|
||||||
|
|
||||||
|
let file_path = url[PREFIX_LEN..].to_string();
|
||||||
|
assert!(!file_path.is_empty());
|
||||||
|
assert_ne!(file_path, "/", "file search should not hit the root path");
|
||||||
|
|
||||||
|
let score = {
|
||||||
|
// The second column is the score, this column may not
|
||||||
|
// exist. We use SCORE if the real value is absent.
|
||||||
|
let score_column = self.cursor.string(1);
|
||||||
|
let opt_score_str = score_column.as_ref().map(|g_str| g_str.as_str());
|
||||||
|
let opt_score = opt_score_str.map(|str| {
|
||||||
|
str.parse::<f64>()
|
||||||
|
.expect("score should be valid for type f64")
|
||||||
|
});
|
||||||
|
|
||||||
|
opt_score.unwrap_or(SCORE)
|
||||||
|
};
|
||||||
|
|
||||||
|
return Some(Ok((file_path, score)));
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
// another try
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn hits(
|
||||||
|
query_string: &str,
|
||||||
|
from: usize,
|
||||||
|
size: usize,
|
||||||
|
config: &FileSearchConfig,
|
||||||
|
) -> Result<Vec<(Document, f64)>, String> {
|
||||||
|
// Special cases that will make querying faster.
|
||||||
|
if query_string.is_empty() || size == 0 || config.search_paths.is_empty() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut result_hits = Vec::with_capacity(size);
|
||||||
|
|
||||||
|
let need_to_skip = {
|
||||||
|
if matches!(config.search_by, SearchBy::Name) {
|
||||||
|
// We don't use full-text search in this case, the returned documents
|
||||||
|
// won't be scored, the query hits won't be sorted, so processing the
|
||||||
|
// from parameter is meaningless.
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
from > 0
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut num_skipped = 0;
|
||||||
|
let should_skip = from;
|
||||||
|
|
||||||
|
let query = Query::new(query_string, config)?;
|
||||||
|
for res_entry in query {
|
||||||
|
let (file_path, score) = res_entry?;
|
||||||
|
|
||||||
|
// This should be called before processing the `from` parameter.
|
||||||
|
if should_be_filtered_out(config, &file_path, true, true, true) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process the `from` parameter.
|
||||||
|
if need_to_skip && num_skipped < should_skip {
|
||||||
|
// Skip this
|
||||||
|
num_skipped += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let icon = sync_get_file_icon(&file_path);
|
||||||
|
let file_path_of_type_path = camino::Utf8Path::new(&file_path);
|
||||||
|
let r#where = file_path_of_type_path
|
||||||
|
.parent()
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
panic!(
|
||||||
|
"expect path [{}] to have a parent, but it does not",
|
||||||
|
file_path
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let file_name = file_path_of_type_path.file_name().unwrap_or_else(|| {
|
||||||
|
panic!(
|
||||||
|
"expect path [{}] to have a file name, but it does not",
|
||||||
|
file_path
|
||||||
|
);
|
||||||
|
});
|
||||||
|
let on_opened = OnOpened::Document {
|
||||||
|
url: file_path.to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let doc = Document {
|
||||||
|
id: file_path.to_string(),
|
||||||
|
title: Some(file_name.to_string()),
|
||||||
|
source: Some(DataSourceReference {
|
||||||
|
r#type: Some(LOCAL_QUERY_SOURCE_TYPE.into()),
|
||||||
|
name: Some(EXTENSION_ID.into()),
|
||||||
|
id: Some(EXTENSION_ID.into()),
|
||||||
|
icon: Some(String::from("font_Filesearch")),
|
||||||
|
}),
|
||||||
|
category: Some(r#where),
|
||||||
|
on_opened: Some(on_opened),
|
||||||
|
url: Some(file_path),
|
||||||
|
icon: Some(icon.to_string()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
result_hits.push((doc, score));
|
||||||
|
|
||||||
|
// Collected enough documents, return
|
||||||
|
if result_hits.len() >= size {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(result_hits)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_path_in_recursive_indexing_scope(list: &mut StrV, path: &str) {
|
||||||
|
for item in list.iter() {
|
||||||
|
let item_path = Utf8Path::new(item.as_str());
|
||||||
|
let path = Utf8Path::new(path);
|
||||||
|
|
||||||
|
// It is already covered or listed
|
||||||
|
if path.starts_with(item_path) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
list.push(
|
||||||
|
GString::from_utf8_checked(path.as_bytes().to_vec())
|
||||||
|
.expect("search_path_str contains an interior NUL"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_path_and_descendants_not_in_single_indexing_scope(list: &mut StrV, path: &str) {
|
||||||
|
// Indexes to the items that should be removed
|
||||||
|
let mut item_to_remove = Vec::new();
|
||||||
|
for (idx, item) in list.iter().enumerate() {
|
||||||
|
let item_path = Utf8Path::new(item.as_str());
|
||||||
|
let path = Utf8Path::new(path);
|
||||||
|
|
||||||
|
if item_path.starts_with(path) {
|
||||||
|
item_to_remove.push(idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reverse the indexes so that the remove operation won't invalidate them.
|
||||||
|
for idx in item_to_remove.into_iter().rev() {
|
||||||
|
list.remove(idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn apply_config(config: &FileSearchConfig) -> Result<(), String> {
|
||||||
|
// Tracker provides the following configuration entries to allow users to
|
||||||
|
// tweak the indexing scope:
|
||||||
|
//
|
||||||
|
// 1. ignored-directories: A list of names, directories with such names will be ignored.
|
||||||
|
// ['po', 'CVS', 'core-dumps', 'lost+found']
|
||||||
|
// 2. ignored-directories-with-content: Avoid any directory containing a file blocklisted here
|
||||||
|
// ['.trackerignore', '.git', '.hg', '.nomedia']
|
||||||
|
// 3. ignored-files: List of file patterns to avoid
|
||||||
|
// ['*~', '*.o', '*.la', '*.lo', '*.loT', '*.in', '*.m4', '*.rej', ...]
|
||||||
|
// 4. index-recursive-directories: List of directories to index recursively
|
||||||
|
// ['&DESKTOP', '&DOCUMENTS', '&MUSIC', '&PICTURES', '&VIDEOS']
|
||||||
|
// 5. index-single-directories: List of directories to index without inspecting subfolders,
|
||||||
|
// ['$HOME', '&DOWNLOAD']
|
||||||
|
//
|
||||||
|
// The first 3 entries specify patterns, in order to use them, we have to walk
|
||||||
|
// through the whole directory tree listed in search paths, which is impractical.
|
||||||
|
// So we only use the last 2 entries.
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// Just want to mention that setting search path to "/home" could break Tracker:
|
||||||
|
//
|
||||||
|
// ```text
|
||||||
|
// Unknown target graph for uri:'file:///home' and mime:'inode/directory'
|
||||||
|
// ```
|
||||||
|
//
|
||||||
|
// See the related bug reports:
|
||||||
|
//
|
||||||
|
// https://gitlab.gnome.org/GNOME/localsearch/-/issues/313
|
||||||
|
// https://bugs.launchpad.net/bugs/2077181
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// There is nothing we can do.
|
||||||
|
|
||||||
|
const TRACKER_SETTINGS_SCHEMA: &str = "org.freedesktop.Tracker3.Miner.Files";
|
||||||
|
const KEY_INDEX_RECURSIVE_DIRECTORIES: &str = "index-recursive-directories";
|
||||||
|
const KEY_INDEX_SINGLE_DIRECTORIES: &str = "index-single-directories";
|
||||||
|
|
||||||
|
let search_paths = &config.search_paths;
|
||||||
|
|
||||||
|
let settings = Settings::new(TRACKER_SETTINGS_SCHEMA);
|
||||||
|
let mut recursive_list: StrV = settings.strv(KEY_INDEX_RECURSIVE_DIRECTORIES);
|
||||||
|
let mut single_list: StrV = settings.strv(KEY_INDEX_SINGLE_DIRECTORIES);
|
||||||
|
|
||||||
|
for search_path in search_paths {
|
||||||
|
// We want our search path to be included in the recursive directories or
|
||||||
|
// any directory within the list covers it.
|
||||||
|
ensure_path_in_recursive_indexing_scope(&mut recursive_list, search_path);
|
||||||
|
// We want our search path and its any descendants are not listed in
|
||||||
|
// the index directories list.
|
||||||
|
ensure_path_and_descendants_not_in_single_indexing_scope(&mut single_list, search_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
settings
|
||||||
|
.set_strv(KEY_INDEX_RECURSIVE_DIRECTORIES, recursive_list)
|
||||||
|
.expect("key is not read-only");
|
||||||
|
settings
|
||||||
|
.set_strv(KEY_INDEX_SINGLE_DIRECTORIES, single_list)
|
||||||
|
.expect("key is not be read-only");
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_query_string_cleanup_basic() {
|
||||||
|
assert_eq!(query_string_cleanup("test"), "test");
|
||||||
|
assert_eq!(query_string_cleanup("hello world"), "hello world");
|
||||||
|
assert_eq!(query_string_cleanup("file.txt"), "file.txt");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_query_string_cleanup_unsupported_chars() {
|
||||||
|
assert_eq!(query_string_cleanup("test'file"), "test file");
|
||||||
|
assert_eq!(query_string_cleanup("test\nfile"), "test file");
|
||||||
|
assert_eq!(query_string_cleanup("test\\file"), "test file");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_query_string_cleanup_multiple_unsupported() {
|
||||||
|
assert_eq!(query_string_cleanup("test'file\nname"), "test file name");
|
||||||
|
assert_eq!(query_string_cleanup("test\'file"), "test file");
|
||||||
|
assert_eq!(query_string_cleanup("\n'test"), " test");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_query_string_cleanup_edge_cases() {
|
||||||
|
assert_eq!(query_string_cleanup(""), "");
|
||||||
|
assert_eq!(query_string_cleanup("'"), " ");
|
||||||
|
assert_eq!(query_string_cleanup("\n"), " ");
|
||||||
|
assert_eq!(query_string_cleanup("\\"), " ");
|
||||||
|
assert_eq!(query_string_cleanup(" '\n\\ "), " ");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_query_string_cleanup_mixed_content() {
|
||||||
|
assert_eq!(
|
||||||
|
query_string_cleanup("document's content\nwith\\backslash"),
|
||||||
|
"document s content with backslash"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
query_string_cleanup("path/to'file\nextension\\test"),
|
||||||
|
"path/to file extension test"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,308 @@
|
|||||||
|
//! File search for KDE, powered by its Baloo engine.
|
||||||
|
|
||||||
|
use super::super::super::EXTENSION_ID;
|
||||||
|
use super::super::super::config::FileSearchConfig;
|
||||||
|
use super::super::super::config::SearchBy;
|
||||||
|
use super::super::should_be_filtered_out;
|
||||||
|
use crate::common::document::{DataSourceReference, Document};
|
||||||
|
use crate::extension::LOCAL_QUERY_SOURCE_TYPE;
|
||||||
|
use crate::extension::OnOpened;
|
||||||
|
use crate::util::file::sync_get_file_icon;
|
||||||
|
use camino::Utf8Path;
|
||||||
|
use configparser::ini::Ini;
|
||||||
|
use configparser::ini::WriteOptions;
|
||||||
|
use futures::stream::Stream;
|
||||||
|
use futures::stream::StreamExt;
|
||||||
|
use std::os::fd::OwnedFd;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use tokio::io::AsyncBufReadExt;
|
||||||
|
use tokio::io::BufReader;
|
||||||
|
use tokio::process::Child;
|
||||||
|
use tokio::process::Command;
|
||||||
|
use tokio_stream::wrappers::LinesStream;
|
||||||
|
|
||||||
|
/// Baloo does not support scoring, use this score for all the documents.
|
||||||
|
const SCORE: f64 = 1.0;
|
||||||
|
|
||||||
|
/// KDE6 updates the binary name to "baloosearch6", but I believe there still have
|
||||||
|
/// distros using the original name. So we need to check both.
|
||||||
|
fn cli_tool_lookup() -> Option<PathBuf> {
|
||||||
|
use which::which;
|
||||||
|
|
||||||
|
let res_path = which("baloosearch").or_else(|_| which("baloosearch6"));
|
||||||
|
res_path.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn hits(
|
||||||
|
query_string: &str,
|
||||||
|
_from: usize,
|
||||||
|
size: usize,
|
||||||
|
config: &FileSearchConfig,
|
||||||
|
) -> Result<Vec<(Document, f64)>, String> {
|
||||||
|
// Special cases that will make querying faster.
|
||||||
|
if query_string.is_empty() || size == 0 || config.search_paths.is_empty() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the tool is not found, return an empty result as well.
|
||||||
|
let Some(tool_path) = cli_tool_lookup() else {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
};
|
||||||
|
|
||||||
|
let (mut iter, _baloosearch_child_process) =
|
||||||
|
execute_baloosearch_query(tool_path, query_string, size, config)?;
|
||||||
|
|
||||||
|
// Convert results to documents
|
||||||
|
let mut hits: Vec<(Document, f64)> = Vec::new();
|
||||||
|
while let Some(res_file_path) = iter.next().await {
|
||||||
|
let file_path = res_file_path.map_err(|io_err| io_err.to_string())?;
|
||||||
|
|
||||||
|
let icon = sync_get_file_icon(&file_path);
|
||||||
|
let file_path_of_type_path = camino::Utf8Path::new(&file_path);
|
||||||
|
let r#where = file_path_of_type_path
|
||||||
|
.parent()
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
panic!(
|
||||||
|
"expect path [{}] to have a parent, but it does not",
|
||||||
|
file_path
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let file_name = file_path_of_type_path.file_name().unwrap_or_else(|| {
|
||||||
|
panic!(
|
||||||
|
"expect path [{}] to have a file name, but it does not",
|
||||||
|
file_path
|
||||||
|
);
|
||||||
|
});
|
||||||
|
let on_opened = OnOpened::Document {
|
||||||
|
url: file_path.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let doc = Document {
|
||||||
|
id: file_path.clone(),
|
||||||
|
title: Some(file_name.to_string()),
|
||||||
|
source: Some(DataSourceReference {
|
||||||
|
r#type: Some(LOCAL_QUERY_SOURCE_TYPE.into()),
|
||||||
|
name: Some(EXTENSION_ID.into()),
|
||||||
|
id: Some(EXTENSION_ID.into()),
|
||||||
|
icon: Some(String::from("font_Filesearch")),
|
||||||
|
}),
|
||||||
|
category: Some(r#where),
|
||||||
|
on_opened: Some(on_opened),
|
||||||
|
url: Some(file_path),
|
||||||
|
icon: Some(icon.to_string()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
hits.push((doc, SCORE));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(hits)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return an array containing the `baloosearch` command and its arguments.
|
||||||
|
fn build_baloosearch_query(
|
||||||
|
tool_path: PathBuf,
|
||||||
|
query_string: &str,
|
||||||
|
config: &FileSearchConfig,
|
||||||
|
) -> Vec<String> {
|
||||||
|
let tool_path = tool_path
|
||||||
|
.into_os_string()
|
||||||
|
.into_string()
|
||||||
|
.expect("binary path should be UTF-8 encoded");
|
||||||
|
|
||||||
|
let mut args = vec![tool_path];
|
||||||
|
|
||||||
|
match config.search_by {
|
||||||
|
SearchBy::Name => {
|
||||||
|
args.push(format!("filename:{query_string}"));
|
||||||
|
}
|
||||||
|
SearchBy::NameAndContents => {
|
||||||
|
args.push(query_string.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for search_path in config.search_paths.iter() {
|
||||||
|
args.extend_from_slice(&["-d".into(), search_path.clone()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
args
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawn the `baloosearch` child process and return an async iterator over its output,
|
||||||
|
/// allowing us to collect the results asynchronously.
|
||||||
|
///
|
||||||
|
/// # Return value:
|
||||||
|
///
|
||||||
|
/// * impl Stream: an async iterator that will yield the matched files
|
||||||
|
/// * Child: The handle to the baloosearch process. The child process will be
|
||||||
|
/// killed when this handle gets dropped so we need to keep it alive util we
|
||||||
|
/// exhaust the stream.
|
||||||
|
fn execute_baloosearch_query(
|
||||||
|
tool_path: PathBuf,
|
||||||
|
query_string: &str,
|
||||||
|
size: usize,
|
||||||
|
config: &FileSearchConfig,
|
||||||
|
) -> Result<(impl Stream<Item = std::io::Result<String>>, Child), String> {
|
||||||
|
let args = build_baloosearch_query(tool_path, query_string, config);
|
||||||
|
|
||||||
|
let (rx, tx) = std::io::pipe().unwrap();
|
||||||
|
let rx_owned = OwnedFd::from(rx);
|
||||||
|
let async_rx = tokio::net::unix::pipe::Receiver::from_owned_fd(rx_owned).unwrap();
|
||||||
|
let buffered_rx = BufReader::new(async_rx);
|
||||||
|
let lines = LinesStream::new(buffered_rx.lines());
|
||||||
|
|
||||||
|
let child = Command::new(&args[0])
|
||||||
|
.args(&args[1..])
|
||||||
|
.stdout(tx)
|
||||||
|
.stderr(std::process::Stdio::null())
|
||||||
|
// The child process will be killed when the Child instance gets dropped.
|
||||||
|
.kill_on_drop(true)
|
||||||
|
.spawn()
|
||||||
|
.map_err(|e| format!("Failed to spawn baloosearch: {e}"))?;
|
||||||
|
let config_clone = config.clone();
|
||||||
|
let iter = lines
|
||||||
|
.filter(move |res_path| {
|
||||||
|
std::future::ready({
|
||||||
|
match res_path {
|
||||||
|
Ok(path) => !should_be_filtered_out(&config_clone, path, false, true, true),
|
||||||
|
Err(_) => {
|
||||||
|
// Don't filter out Err() values
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.take(size);
|
||||||
|
|
||||||
|
Ok((iter, child))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn apply_config(config: &FileSearchConfig) -> Result<(), String> {
|
||||||
|
// Users can tweak Baloo via its configuration file, below are the fields that
|
||||||
|
// we need to modify:
|
||||||
|
//
|
||||||
|
// * Indexing-Enabled: turn indexing on or off
|
||||||
|
// * only basic indexing: If true, Baloo only indexes file names
|
||||||
|
// * folders: directories to index
|
||||||
|
// * exclude folders: directories to skip
|
||||||
|
//
|
||||||
|
// ```ini
|
||||||
|
// [Basic Settings]
|
||||||
|
// Indexing-Enabled=true
|
||||||
|
//
|
||||||
|
// [General]
|
||||||
|
// only basic indexing=true
|
||||||
|
// folders[$e]=$HOME/
|
||||||
|
// exclude folders[$e]=$HOME/FolderA/,$HOME/FolderB/
|
||||||
|
// ```
|
||||||
|
|
||||||
|
const SECTION_GENERAL: &str = "General";
|
||||||
|
const KEY_INCLUDE_FOLDERS: &str = "folders[$e]";
|
||||||
|
const KEY_EXCLUDE_FOLDERS: &str = "exclude folders[$e]";
|
||||||
|
const FOLDERS_SEPARATOR: &str = ",";
|
||||||
|
|
||||||
|
let rc_file_path = {
|
||||||
|
let mut home = dirs::home_dir()
|
||||||
|
.expect("cannot find the home directory, Coco should never run in such a environment");
|
||||||
|
home.push(".config/baloofilerc");
|
||||||
|
home
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse and load the rc file, it is in format INI
|
||||||
|
//
|
||||||
|
// Use `new_cs()`, the case-sensitive version of constructor as the config
|
||||||
|
// file contains uppercase letters, so it is case-sensitive.
|
||||||
|
let mut baloo_config = Ini::new_cs();
|
||||||
|
if rc_file_path.try_exists().map_err(|e| e.to_string())? {
|
||||||
|
let _ = baloo_config.load(rc_file_path.as_path())?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure indexing is enabled
|
||||||
|
let _ = baloo_config.setstr("Basic Settings", "Indexing-Enabled", Some("true"));
|
||||||
|
|
||||||
|
// Let baloo index file content if we need that
|
||||||
|
if config.search_by == SearchBy::NameAndContents {
|
||||||
|
let _ = baloo_config.setstr(SECTION_GENERAL, "only basic indexing", Some("false"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut include_folders = {
|
||||||
|
match baloo_config.get(SECTION_GENERAL, KEY_INCLUDE_FOLDERS) {
|
||||||
|
Some(str) => str
|
||||||
|
.split(FOLDERS_SEPARATOR)
|
||||||
|
.map(|str| str.to_string())
|
||||||
|
.collect::<Vec<String>>(),
|
||||||
|
None => Vec::new(),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut exclude_folders = {
|
||||||
|
match baloo_config.get(SECTION_GENERAL, KEY_EXCLUDE_FOLDERS) {
|
||||||
|
Some(str) => str
|
||||||
|
.split(FOLDERS_SEPARATOR)
|
||||||
|
.map(|str| str.to_string())
|
||||||
|
.collect::<Vec<String>>(),
|
||||||
|
None => Vec::new(),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fn ensure_path_included_include_folders(
|
||||||
|
include_folders: &mut Vec<String>,
|
||||||
|
search_path: &Utf8Path,
|
||||||
|
) {
|
||||||
|
for include_folder in include_folders.iter() {
|
||||||
|
let include_folder = Utf8Path::new(include_folder.as_str());
|
||||||
|
if search_path.starts_with(include_folder) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
include_folders.push(search_path.as_str().to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_path_and_descendants_not_excluded(
|
||||||
|
exclude_folders: &mut Vec<String>,
|
||||||
|
search_path: &Utf8Path,
|
||||||
|
) {
|
||||||
|
let mut items_to_remove = Vec::new();
|
||||||
|
for (idx, exclude_folder) in exclude_folders.iter().enumerate() {
|
||||||
|
let exclude_folder = Utf8Path::new(exclude_folder);
|
||||||
|
|
||||||
|
if exclude_folder.starts_with(search_path) {
|
||||||
|
items_to_remove.push(idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for idx in items_to_remove.into_iter().rev() {
|
||||||
|
exclude_folders.remove(idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for search_path in config.search_paths.iter() {
|
||||||
|
let search_path = Utf8Path::new(search_path.as_str());
|
||||||
|
|
||||||
|
ensure_path_included_include_folders(&mut include_folders, search_path);
|
||||||
|
ensure_path_and_descendants_not_excluded(&mut exclude_folders, search_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
let include_folders_str: String = include_folders.as_slice().join(FOLDERS_SEPARATOR);
|
||||||
|
let exclude_folders_str: String = exclude_folders.as_slice().join(FOLDERS_SEPARATOR);
|
||||||
|
|
||||||
|
let _ = baloo_config.set(
|
||||||
|
SECTION_GENERAL,
|
||||||
|
KEY_INCLUDE_FOLDERS,
|
||||||
|
Some(include_folders_str),
|
||||||
|
);
|
||||||
|
let _ = baloo_config.set(
|
||||||
|
SECTION_GENERAL,
|
||||||
|
KEY_EXCLUDE_FOLDERS,
|
||||||
|
Some(exclude_folders_str),
|
||||||
|
);
|
||||||
|
|
||||||
|
baloo_config
|
||||||
|
.pretty_write(rc_file_path.as_path(), &WriteOptions::new())
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
mod gnome;
|
||||||
|
mod kde;
|
||||||
|
|
||||||
|
use super::super::config::FileSearchConfig;
|
||||||
|
use crate::common::document::Document;
|
||||||
|
use crate::util::LinuxDesktopEnvironment;
|
||||||
|
use crate::util::get_linux_desktop_environment;
|
||||||
|
use std::ops::Deref;
|
||||||
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
|
static DESKTOP_ENVIRONMENT: LazyLock<Option<LinuxDesktopEnvironment>> =
|
||||||
|
LazyLock::new(|| get_linux_desktop_environment());
|
||||||
|
|
||||||
|
/// Dispatch to implementations powered by different backends.
|
||||||
|
pub(crate) async fn hits(
|
||||||
|
query_string: &str,
|
||||||
|
from: usize,
|
||||||
|
size: usize,
|
||||||
|
config: &FileSearchConfig,
|
||||||
|
) -> Result<Vec<(Document, f64)>, String> {
|
||||||
|
let de = DESKTOP_ENVIRONMENT.deref();
|
||||||
|
match de {
|
||||||
|
Some(LinuxDesktopEnvironment::Gnome) => gnome::hits(query_string, from, size, config).await,
|
||||||
|
Some(LinuxDesktopEnvironment::Kde) => kde::hits(query_string, from, size, config).await,
|
||||||
|
Some(LinuxDesktopEnvironment::Unsupported {
|
||||||
|
xdg_current_desktop: _,
|
||||||
|
}) => {
|
||||||
|
return Err("file search is not supported on this desktop environment".into());
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
return Err("could not determine Linux desktop environment".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn apply_config(config: &FileSearchConfig) -> Result<(), String> {
|
||||||
|
let de = DESKTOP_ENVIRONMENT.deref();
|
||||||
|
match de {
|
||||||
|
Some(LinuxDesktopEnvironment::Gnome) => gnome::apply_config(config),
|
||||||
|
Some(LinuxDesktopEnvironment::Kde) => kde::apply_config(config),
|
||||||
|
Some(LinuxDesktopEnvironment::Unsupported {
|
||||||
|
xdg_current_desktop: _,
|
||||||
|
}) => {
|
||||||
|
return Err("file search is not supported on this desktop environment".into());
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
return Err("could not determine Linux desktop environment".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
use super::super::EXTENSION_ID;
|
use super::super::EXTENSION_ID;
|
||||||
use super::super::config::FileSearchConfig;
|
use super::super::config::FileSearchConfig;
|
||||||
use super::super::config::SearchBy;
|
use super::super::config::SearchBy;
|
||||||
|
use super::should_be_filtered_out;
|
||||||
use crate::common::document::{DataSourceReference, Document};
|
use crate::common::document::{DataSourceReference, Document};
|
||||||
use crate::extension::LOCAL_QUERY_SOURCE_TYPE;
|
use crate::extension::LOCAL_QUERY_SOURCE_TYPE;
|
||||||
use crate::extension::OnOpened;
|
use crate::extension::OnOpened;
|
||||||
use crate::util::file::get_file_icon;
|
use crate::util::file::sync_get_file_icon;
|
||||||
use futures::stream::Stream;
|
use futures::stream::Stream;
|
||||||
use futures::stream::StreamExt;
|
use futures::stream::StreamExt;
|
||||||
use std::os::fd::OwnedFd;
|
use std::os::fd::OwnedFd;
|
||||||
@@ -24,7 +25,7 @@ pub(crate) async fn hits(
|
|||||||
size: usize,
|
size: usize,
|
||||||
config: &FileSearchConfig,
|
config: &FileSearchConfig,
|
||||||
) -> Result<Vec<(Document, f64)>, String> {
|
) -> Result<Vec<(Document, f64)>, String> {
|
||||||
let (mut iter, mut mdfind_child_process) =
|
let (mut iter, _mdfind_child_process) =
|
||||||
execute_mdfind_query(&query_string, from, size, &config)?;
|
execute_mdfind_query(&query_string, from, size, &config)?;
|
||||||
|
|
||||||
// Convert results to documents
|
// Convert results to documents
|
||||||
@@ -32,7 +33,7 @@ pub(crate) async fn hits(
|
|||||||
while let Some(res_file_path) = iter.next().await {
|
while let Some(res_file_path) = iter.next().await {
|
||||||
let file_path = res_file_path.map_err(|io_err| io_err.to_string())?;
|
let file_path = res_file_path.map_err(|io_err| io_err.to_string())?;
|
||||||
|
|
||||||
let icon = get_file_icon(file_path.clone()).await;
|
let icon = sync_get_file_icon(&file_path);
|
||||||
let file_path_of_type_path = camino::Utf8Path::new(&file_path);
|
let file_path_of_type_path = camino::Utf8Path::new(&file_path);
|
||||||
let r#where = file_path_of_type_path
|
let r#where = file_path_of_type_path
|
||||||
.parent()
|
.parent()
|
||||||
@@ -72,12 +73,6 @@ pub(crate) async fn hits(
|
|||||||
|
|
||||||
hits.push((doc, SCORE));
|
hits.push((doc, SCORE));
|
||||||
}
|
}
|
||||||
// Kill the mdfind process once we get the needed results to prevent zombie
|
|
||||||
// processes.
|
|
||||||
mdfind_child_process
|
|
||||||
.kill()
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("{:?}", e))?;
|
|
||||||
|
|
||||||
Ok(hits)
|
Ok(hits)
|
||||||
}
|
}
|
||||||
@@ -88,13 +83,28 @@ fn build_mdfind_query(query_string: &str, config: &FileSearchConfig) -> Vec<Stri
|
|||||||
|
|
||||||
match config.search_by {
|
match config.search_by {
|
||||||
SearchBy::Name => {
|
SearchBy::Name => {
|
||||||
args.push(format!("kMDItemFSName == '*{}*'", query_string));
|
// The tailing char 'c' makes the search case-insensitive.
|
||||||
|
//
|
||||||
|
// According to [1], we should use this syntax "kMDItemFSName ==[c] '*{}*'",
|
||||||
|
// but it does not work on my machine (macOS 26 beta 7), and you
|
||||||
|
// can find similar complaints as well [2].
|
||||||
|
//
|
||||||
|
// [1]: https://developer.apple.com/library/archive/documentation/Carbon/Conceptual/SpotlightQuery/Concepts/QueryFormat.html
|
||||||
|
// [2]: https://apple.stackexchange.com/q/263671/394687
|
||||||
|
args.push(format!("kMDItemFSName == '*{}*'c", query_string));
|
||||||
}
|
}
|
||||||
SearchBy::NameAndContents => {
|
SearchBy::NameAndContents => {
|
||||||
args.push(format!(
|
// Do not specify any File System Metadata Attribute Keys to search
|
||||||
"kMDItemFSName == '*{}*' || kMDItemTextContent == '{}'",
|
// all of them, it is case-insensitive by default.
|
||||||
query_string, query_string
|
//
|
||||||
));
|
// Previously, we use:
|
||||||
|
//
|
||||||
|
// "kMDItemFSName == '*{}*' || kMDItemTextContent == '{}'"
|
||||||
|
//
|
||||||
|
// But the kMDItemTextContent attribute does not work as expected.
|
||||||
|
// For example, if a PDF document contains both "Waterloo" and
|
||||||
|
// "waterloo", it is only matched by "Waterloo".
|
||||||
|
args.push(query_string.to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,8 +124,9 @@ fn build_mdfind_query(query_string: &str, config: &FileSearchConfig) -> Vec<Stri
|
|||||||
/// # Return value:
|
/// # Return value:
|
||||||
///
|
///
|
||||||
/// * impl Stream: an async iterator that will yield the matched files
|
/// * impl Stream: an async iterator that will yield the matched files
|
||||||
/// * Child: The handle to the mdfind process, we need to kill it once we
|
/// * Child: The handle to the mdfind process. The child process will be killed
|
||||||
/// collect all the results to avoid zombie processes.
|
/// when this handle gets dropped, we need to keep it alive until we exhaust
|
||||||
|
/// all the query results.
|
||||||
fn execute_mdfind_query(
|
fn execute_mdfind_query(
|
||||||
query_string: &str,
|
query_string: &str,
|
||||||
from: usize,
|
from: usize,
|
||||||
@@ -133,6 +144,7 @@ fn execute_mdfind_query(
|
|||||||
.args(&args[1..])
|
.args(&args[1..])
|
||||||
.stdout(tx)
|
.stdout(tx)
|
||||||
.stderr(std::process::Stdio::null())
|
.stderr(std::process::Stdio::null())
|
||||||
|
.kill_on_drop(true)
|
||||||
.spawn()
|
.spawn()
|
||||||
.map_err(|e| format!("Failed to spawn mdfind: {}", e))?;
|
.map_err(|e| format!("Failed to spawn mdfind: {}", e))?;
|
||||||
let config_clone = config.clone();
|
let config_clone = config.clone();
|
||||||
@@ -140,7 +152,7 @@ fn execute_mdfind_query(
|
|||||||
.filter(move |res_path| {
|
.filter(move |res_path| {
|
||||||
std::future::ready({
|
std::future::ready({
|
||||||
match res_path {
|
match res_path {
|
||||||
Ok(path) => !should_be_filtered_out(&config_clone, path),
|
Ok(path) => !should_be_filtered_out(&config_clone, path, false, true, true),
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
// Don't filter out Err() values
|
// Don't filter out Err() values
|
||||||
true
|
true
|
||||||
@@ -154,33 +166,25 @@ fn execute_mdfind_query(
|
|||||||
Ok((iter, child))
|
Ok((iter, child))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// If `file_path` should be removed from the search results given the filter
|
pub(crate) fn apply_config(_: &FileSearchConfig) -> Result<(), String> {
|
||||||
/// conditions specified in `config`.
|
// By default, macOS indexes all the files within a volume if indexing is
|
||||||
fn should_be_filtered_out(config: &FileSearchConfig, file_path: &str) -> bool {
|
// enabled. So, to ensure our search paths are indexed by Spotlight,
|
||||||
let is_excluded = config
|
// theoretically, we can do the following things:
|
||||||
.exclude_paths
|
//
|
||||||
.iter()
|
// 1. Ensure indexing is enabled on the volumes where our search paths reside.
|
||||||
.any(|exclude_path| file_path.starts_with(exclude_path));
|
// However, we cannot do this as doing so requires `sudo`.
|
||||||
|
//
|
||||||
if is_excluded {
|
// 2. Ensure the search paths are not excluded from indexing scope. Users can
|
||||||
return true;
|
// stop Spotlight from indexing a directory by:
|
||||||
}
|
// 1. adding it to the "Privacy" list in 'System Settings'. Coco cannot
|
||||||
|
// modify this list, since the only way to change it is manually
|
||||||
let matches_file_type = if config.file_types.is_empty() {
|
// through System Settings.
|
||||||
true
|
// 2. Renaming directory name, adding a `.noindex` file extension to it.
|
||||||
} else {
|
// I don't want to use this trick, users won't feel comfortable and it
|
||||||
let path_obj = camino::Utf8Path::new(&file_path);
|
// could break at any time.
|
||||||
if let Some(extension) = path_obj.extension() {
|
// 3. Creating a `.metadata_never_index` file within the directory (no longer works
|
||||||
config
|
// since macOS Mojave)
|
||||||
.file_types
|
//
|
||||||
.iter()
|
// There is nothing we can do.
|
||||||
.any(|file_type| file_type == extension)
|
Ok(())
|
||||||
} else {
|
|
||||||
// `config.file_types` is not empty, then the search results
|
|
||||||
// should have extensions.
|
|
||||||
false
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
!matches_file_type
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,396 @@
|
|||||||
#[cfg(target_os = "macos")]
|
use cfg_if::cfg_if;
|
||||||
mod macos;
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
mod windows;
|
|
||||||
|
|
||||||
// `hits()` function is platform-specific, export the corresponding impl.
|
// * hits: the implementation of search
|
||||||
#[cfg(target_os = "macos")]
|
//
|
||||||
pub(crate) use macos::hits;
|
// * apply_config: Routines that should be performed to keep "other things"
|
||||||
#[cfg(target_os = "windows")]
|
// synchronous with the passed configuration.
|
||||||
pub(crate) use windows::hits;
|
// Currently, "other things" only include system indexer's setting entries.
|
||||||
|
cfg_if! {
|
||||||
|
if #[cfg(target_os = "linux")] {
|
||||||
|
mod linux;
|
||||||
|
pub(crate) use linux::hits;
|
||||||
|
pub(crate) use linux::apply_config;
|
||||||
|
} else if #[cfg(target_os = "macos")] {
|
||||||
|
mod macos;
|
||||||
|
pub(crate) use macos::hits;
|
||||||
|
pub(crate) use macos::apply_config;
|
||||||
|
} else if #[cfg(target_os = "windows")] {
|
||||||
|
mod windows;
|
||||||
|
pub(crate) use windows::hits;
|
||||||
|
pub(crate) use windows::apply_config;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg_if! {
|
||||||
|
if #[cfg(not(target_os = "windows"))] {
|
||||||
|
use super::config::FileSearchConfig;
|
||||||
|
use camino::Utf8Path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// If `file_path` should be removed from the search results given the filter
|
||||||
|
/// conditions specified in `config`.
|
||||||
|
#[cfg(not(target_os = "windows"))] // Not used on Windows
|
||||||
|
pub(crate) fn should_be_filtered_out(
|
||||||
|
config: &FileSearchConfig,
|
||||||
|
file_path: &str,
|
||||||
|
check_search_paths: bool,
|
||||||
|
check_exclude_paths: bool,
|
||||||
|
check_file_type: bool,
|
||||||
|
) -> bool {
|
||||||
|
let file_path = Utf8Path::new(file_path);
|
||||||
|
|
||||||
|
if check_search_paths {
|
||||||
|
// search path
|
||||||
|
let in_search_paths = config.search_paths.iter().any(|search_path| {
|
||||||
|
let search_path = Utf8Path::new(search_path);
|
||||||
|
file_path.starts_with(search_path)
|
||||||
|
});
|
||||||
|
if !in_search_paths {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if check_exclude_paths {
|
||||||
|
// exclude path
|
||||||
|
let is_excluded = config
|
||||||
|
.exclude_paths
|
||||||
|
.iter()
|
||||||
|
.any(|exclude_path| file_path.starts_with(exclude_path));
|
||||||
|
if is_excluded {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if check_file_type {
|
||||||
|
// file type
|
||||||
|
let matches_file_type = if config.file_types.is_empty() {
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
let path_obj = camino::Utf8Path::new(&file_path);
|
||||||
|
if let Some(extension) = path_obj.extension() {
|
||||||
|
config
|
||||||
|
.file_types
|
||||||
|
.iter()
|
||||||
|
.any(|file_type| file_type == extension)
|
||||||
|
} else {
|
||||||
|
// `config.file_types` is not empty, the hit files should have extensions.
|
||||||
|
false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if !matches_file_type {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
// should_be_filtered_out() is not defined for Windows
|
||||||
|
#[cfg(all(test, not(target_os = "windows")))]
|
||||||
|
mod tests {
|
||||||
|
use super::super::config::SearchBy;
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_should_be_filtered_out_with_no_check() {
|
||||||
|
let config = FileSearchConfig {
|
||||||
|
search_paths: vec!["/home/user/Documents".to_string()],
|
||||||
|
exclude_paths: vec![],
|
||||||
|
file_types: vec!["fffffff".into()],
|
||||||
|
search_by: SearchBy::Name,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(!should_be_filtered_out(
|
||||||
|
&config, "abbc", false, false, false
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_should_be_filtered_out_search_paths() {
|
||||||
|
let config = FileSearchConfig {
|
||||||
|
search_paths: vec![
|
||||||
|
"/home/user/Documents".to_string(),
|
||||||
|
"/home/user/Downloads".to_string(),
|
||||||
|
],
|
||||||
|
exclude_paths: vec![],
|
||||||
|
file_types: vec![],
|
||||||
|
search_by: SearchBy::Name,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Files in search paths should not be filtered
|
||||||
|
assert!(!should_be_filtered_out(
|
||||||
|
&config,
|
||||||
|
"/home/user/Documents/file.txt",
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
));
|
||||||
|
assert!(!should_be_filtered_out(
|
||||||
|
&config,
|
||||||
|
"/home/user/Downloads/image.jpg",
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
));
|
||||||
|
assert!(!should_be_filtered_out(
|
||||||
|
&config,
|
||||||
|
"/home/user/Documents/folder/file.txt",
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
));
|
||||||
|
|
||||||
|
// Files not in search paths should be filtered
|
||||||
|
assert!(should_be_filtered_out(
|
||||||
|
&config,
|
||||||
|
"/home/user/Pictures/photo.jpg",
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
));
|
||||||
|
assert!(should_be_filtered_out(
|
||||||
|
&config,
|
||||||
|
"/tmp/tempfile",
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
));
|
||||||
|
assert!(should_be_filtered_out(
|
||||||
|
&config,
|
||||||
|
"/usr/bin/ls",
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_should_be_filtered_out_exclude_paths() {
|
||||||
|
let config = FileSearchConfig {
|
||||||
|
search_paths: vec!["/home/user".to_string()],
|
||||||
|
exclude_paths: vec![
|
||||||
|
"/home/user/Trash".to_string(),
|
||||||
|
"/home/user/.cache".to_string(),
|
||||||
|
],
|
||||||
|
file_types: vec![],
|
||||||
|
search_by: SearchBy::Name,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Files in search paths but not excluded should not be filtered
|
||||||
|
assert!(!should_be_filtered_out(
|
||||||
|
&config,
|
||||||
|
"/home/user/Documents/file.txt",
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
));
|
||||||
|
assert!(!should_be_filtered_out(
|
||||||
|
&config,
|
||||||
|
"/home/user/Downloads/image.jpg",
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
));
|
||||||
|
|
||||||
|
// Files in excluded paths should be filtered
|
||||||
|
assert!(should_be_filtered_out(
|
||||||
|
&config,
|
||||||
|
"/home/user/Trash/deleted_file",
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
));
|
||||||
|
assert!(should_be_filtered_out(
|
||||||
|
&config,
|
||||||
|
"/home/user/.cache/temp",
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
));
|
||||||
|
assert!(should_be_filtered_out(
|
||||||
|
&config,
|
||||||
|
"/home/user/Trash/folder/file.txt",
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_should_be_filtered_out_file_types() {
|
||||||
|
let config = FileSearchConfig {
|
||||||
|
search_paths: vec!["/home/user/Documents".to_string()],
|
||||||
|
exclude_paths: vec![],
|
||||||
|
file_types: vec!["txt".to_string(), "md".to_string()],
|
||||||
|
search_by: SearchBy::Name,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Files with allowed extensions should not be filtered
|
||||||
|
assert!(!should_be_filtered_out(
|
||||||
|
&config,
|
||||||
|
"/home/user/Documents/notes.txt",
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
));
|
||||||
|
assert!(!should_be_filtered_out(
|
||||||
|
&config,
|
||||||
|
"/home/user/Documents/readme.md",
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
));
|
||||||
|
|
||||||
|
// Files with disallowed extensions should be filtered
|
||||||
|
assert!(should_be_filtered_out(
|
||||||
|
&config,
|
||||||
|
"/home/user/Documents/image.jpg",
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
));
|
||||||
|
assert!(should_be_filtered_out(
|
||||||
|
&config,
|
||||||
|
"/home/user/Documents/document.pdf",
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
));
|
||||||
|
|
||||||
|
// Files without extensions should be filtered when file_types is not empty
|
||||||
|
assert!(should_be_filtered_out(
|
||||||
|
&config,
|
||||||
|
"/home/user/Documents/file",
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
));
|
||||||
|
assert!(should_be_filtered_out(
|
||||||
|
&config,
|
||||||
|
"/home/user/Documents/folder",
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_should_be_filtered_out_empty_file_types() {
|
||||||
|
let config = FileSearchConfig {
|
||||||
|
search_paths: vec!["/home/user/Documents".to_string()],
|
||||||
|
exclude_paths: vec![],
|
||||||
|
file_types: vec![],
|
||||||
|
search_by: SearchBy::Name,
|
||||||
|
};
|
||||||
|
|
||||||
|
// When file_types is empty, all file types should be allowed
|
||||||
|
assert!(!should_be_filtered_out(
|
||||||
|
&config,
|
||||||
|
"/home/user/Documents/file.txt",
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
));
|
||||||
|
assert!(!should_be_filtered_out(
|
||||||
|
&config,
|
||||||
|
"/home/user/Documents/image.jpg",
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
));
|
||||||
|
assert!(!should_be_filtered_out(
|
||||||
|
&config,
|
||||||
|
"/home/user/Documents/document",
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
));
|
||||||
|
assert!(!should_be_filtered_out(
|
||||||
|
&config,
|
||||||
|
"/home/user/Documents/folder/",
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_should_be_filtered_out_combined_filters() {
|
||||||
|
let config = FileSearchConfig {
|
||||||
|
search_paths: vec!["/home/user".to_string()],
|
||||||
|
exclude_paths: vec!["/home/user/Trash".to_string()],
|
||||||
|
file_types: vec!["txt".to_string()],
|
||||||
|
search_by: SearchBy::Name,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Should pass all filters: in search path, not excluded, and correct file type
|
||||||
|
assert!(!should_be_filtered_out(
|
||||||
|
&config,
|
||||||
|
"/home/user/Documents/notes.txt",
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
));
|
||||||
|
|
||||||
|
// Fails file type filter
|
||||||
|
assert!(should_be_filtered_out(
|
||||||
|
&config,
|
||||||
|
"/home/user/Documents/image.jpg",
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
));
|
||||||
|
|
||||||
|
// Fails exclude path filter
|
||||||
|
assert!(should_be_filtered_out(
|
||||||
|
&config,
|
||||||
|
"/home/user/Trash/deleted.txt",
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
));
|
||||||
|
|
||||||
|
// Fails search path filter
|
||||||
|
assert!(should_be_filtered_out(
|
||||||
|
&config,
|
||||||
|
"/tmp/temp.txt",
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_should_be_filtered_out_edge_cases() {
|
||||||
|
let config = FileSearchConfig {
|
||||||
|
search_paths: vec!["/home/user".to_string()],
|
||||||
|
exclude_paths: vec![],
|
||||||
|
file_types: vec!["txt".to_string()],
|
||||||
|
search_by: SearchBy::Name,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Empty path
|
||||||
|
assert!(should_be_filtered_out(&config, "", true, true, true));
|
||||||
|
|
||||||
|
// Root path
|
||||||
|
assert!(should_be_filtered_out(&config, "/", true, true, true));
|
||||||
|
|
||||||
|
// Path that starts with search path but continues differently
|
||||||
|
assert!(!should_be_filtered_out(
|
||||||
|
&config,
|
||||||
|
"/home/user/document.txt",
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
));
|
||||||
|
assert!(should_be_filtered_out(
|
||||||
|
&config,
|
||||||
|
"/home/user_other/file.txt",
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,234 @@
|
|||||||
|
//! Wraps Windows `ISearchCrawlScopeManager`
|
||||||
|
|
||||||
|
mod searchapi_h_bindings;
|
||||||
|
|
||||||
|
use searchapi_h_bindings::CLSID_CSEARCH_MANAGER;
|
||||||
|
use searchapi_h_bindings::IID_ISEARCH_MANAGER;
|
||||||
|
use searchapi_h_bindings::{
|
||||||
|
HRESULT, ISearchCatalogManager, ISearchCatalogManagerVtbl, ISearchCrawlScopeManager,
|
||||||
|
ISearchCrawlScopeManagerVtbl, ISearchManager,
|
||||||
|
};
|
||||||
|
use std::ffi::OsStr;
|
||||||
|
use std::ffi::OsString;
|
||||||
|
use std::os::windows::ffi::OsStrExt;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::ptr::null_mut;
|
||||||
|
use windows::core::w;
|
||||||
|
use windows_sys::Win32::Foundation::S_OK;
|
||||||
|
use windows_sys::Win32::System::Com::{
|
||||||
|
CLSCTX_LOCAL_SERVER, COINIT_APARTMENTTHREADED, CoCreateInstance, CoInitializeEx, CoUninitialize,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
#[error("{msg}, function [{function}], HRESULT [{hresult}]")]
|
||||||
|
pub(crate) struct WindowSearchApiError {
|
||||||
|
function: &'static str,
|
||||||
|
hresult: HRESULT,
|
||||||
|
msg: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// See doc of [`Rule`].
|
||||||
|
#[derive(Debug, PartialEq)]
|
||||||
|
pub(crate) enum RuleMode {
|
||||||
|
Inclusion,
|
||||||
|
Exclusion,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A rule adds or removes one or more paths to/from the Windows Search index.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub(crate) struct Rule {
|
||||||
|
/// A path or path pattern (wildcard supported, only for exclusion rule) that
|
||||||
|
/// specifies the paths that this rule applies to.
|
||||||
|
///
|
||||||
|
/// The rules used by Windows Search actually specify URLs rather than paths,
|
||||||
|
/// but we only care about paths, i.e., URLs with schema `file://`
|
||||||
|
pub(crate) paths: PathBuf,
|
||||||
|
/// Add or remove paths to/from the index.
|
||||||
|
pub(crate) mode: RuleMode,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A wrapper around Window's `ISearchCrawlScopeManager` type
|
||||||
|
pub(crate) struct CrawlScopeManager {
|
||||||
|
i_search_crawl_scope_manager: *mut ISearchCrawlScopeManager,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CrawlScopeManager {
|
||||||
|
fn vtable(&self) -> *mut ISearchCrawlScopeManagerVtbl {
|
||||||
|
unsafe { (*self.i_search_crawl_scope_manager).lpVtbl }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn new() -> Result<Self, WindowSearchApiError> {
|
||||||
|
unsafe {
|
||||||
|
// 1. Initialize the COM library, use Apartment-threading as Self is not Send/Sync
|
||||||
|
let hr = CoInitializeEx(null_mut(), COINIT_APARTMENTTHREADED as u32);
|
||||||
|
if hr != S_OK {
|
||||||
|
return Err(WindowSearchApiError {
|
||||||
|
function: "CoInitializeEx()",
|
||||||
|
hresult: hr,
|
||||||
|
msg: "failed to initialize the COM library".into(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Create an instance of the CSearchManager.
|
||||||
|
let mut search_manager: *mut ISearchManager = null_mut();
|
||||||
|
let hr = CoCreateInstance(
|
||||||
|
&CLSID_CSEARCH_MANAGER, // CLSID of the object
|
||||||
|
null_mut(), // No outer unknown
|
||||||
|
CLSCTX_LOCAL_SERVER, // Server context
|
||||||
|
&IID_ISEARCH_MANAGER, // IID of the interface we want
|
||||||
|
&mut search_manager as *mut _ as *mut _, // Pointer to receive the interface
|
||||||
|
);
|
||||||
|
if hr != S_OK {
|
||||||
|
return Err(WindowSearchApiError {
|
||||||
|
function: "CoCreateInstance()",
|
||||||
|
hresult: hr,
|
||||||
|
msg: "failed to initialize ISearchManager".into(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
assert!(!search_manager.is_null());
|
||||||
|
|
||||||
|
let search_manger_vtable = (*search_manager).lpVtbl;
|
||||||
|
let search_manager_fn_get_catalog = (*search_manger_vtable).GetCatalog.unwrap();
|
||||||
|
let mut search_catalog_manager: *mut ISearchCatalogManager = null_mut();
|
||||||
|
let string_literal_system_index = w!("SystemIndex");
|
||||||
|
let hr: HRESULT = search_manager_fn_get_catalog(
|
||||||
|
search_manager,
|
||||||
|
string_literal_system_index.0,
|
||||||
|
&mut search_catalog_manager as *mut *mut ISearchCatalogManager,
|
||||||
|
);
|
||||||
|
if hr != S_OK {
|
||||||
|
return Err(WindowSearchApiError {
|
||||||
|
function: "ISearchManager::GetCatalog()",
|
||||||
|
hresult: hr,
|
||||||
|
msg: "failed to initialize ISearchCatalogManager".into(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
assert!(!search_catalog_manager.is_null());
|
||||||
|
|
||||||
|
let search_catalog_manager_vtable: *mut ISearchCatalogManagerVtbl =
|
||||||
|
(*search_catalog_manager).lpVtbl;
|
||||||
|
let fn_get_crawl_scope_manager = (*search_catalog_manager_vtable)
|
||||||
|
.GetCrawlScopeManager
|
||||||
|
.unwrap();
|
||||||
|
let mut search_crawl_scope_manager: *mut ISearchCrawlScopeManager = null_mut();
|
||||||
|
let hr =
|
||||||
|
fn_get_crawl_scope_manager(search_catalog_manager, &mut search_crawl_scope_manager);
|
||||||
|
if hr != S_OK {
|
||||||
|
return Err(WindowSearchApiError {
|
||||||
|
function: "ISearchCatalogManager::GetCrawlScopeManager()",
|
||||||
|
hresult: hr,
|
||||||
|
msg: "failed to initialize ISearchCrawlScopeManager".into(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
assert!(!search_crawl_scope_manager.is_null());
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
i_search_crawl_scope_manager: search_crawl_scope_manager,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Does nothing unless you [`commit()`] the changes.
|
||||||
|
pub(crate) fn add_rule(&mut self, rule: Rule) -> Result<(), WindowSearchApiError> {
|
||||||
|
unsafe {
|
||||||
|
let vtable = self.vtable();
|
||||||
|
|
||||||
|
let fn_add_rule = (*vtable).AddUserScopeRule.unwrap();
|
||||||
|
|
||||||
|
let url: Vec<u16> = encode_path(&rule.paths);
|
||||||
|
let inclusion = (rule.mode == RuleMode::Inclusion) as i32;
|
||||||
|
let override_child_rules = true as i32;
|
||||||
|
let follow_flag = 0x1_u32; /* FF_INDEXCOMPLEXURLS */
|
||||||
|
|
||||||
|
let hr = fn_add_rule(
|
||||||
|
self.i_search_crawl_scope_manager,
|
||||||
|
url.as_ptr(),
|
||||||
|
inclusion,
|
||||||
|
override_child_rules,
|
||||||
|
follow_flag,
|
||||||
|
);
|
||||||
|
|
||||||
|
if hr != S_OK {
|
||||||
|
return Err(WindowSearchApiError {
|
||||||
|
function: "ISearchCrawlScopeManager::AddUserScopeRule()",
|
||||||
|
hresult: hr,
|
||||||
|
msg: "failed to add scope rule".into(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn is_path_included<P: AsRef<Path> + ?Sized>(
|
||||||
|
&self,
|
||||||
|
path: &P,
|
||||||
|
) -> Result<bool, WindowSearchApiError> {
|
||||||
|
unsafe {
|
||||||
|
let vtable = self.vtable();
|
||||||
|
let fn_included_in_crawl_scope = (*vtable).IncludedInCrawlScope.unwrap();
|
||||||
|
let path: Vec<u16> = encode_path(path);
|
||||||
|
|
||||||
|
let mut included: i32 = 0 /* false */;
|
||||||
|
|
||||||
|
let hr = fn_included_in_crawl_scope(
|
||||||
|
self.i_search_crawl_scope_manager,
|
||||||
|
path.as_ptr(),
|
||||||
|
&mut included,
|
||||||
|
);
|
||||||
|
if hr != S_OK {
|
||||||
|
return Err(WindowSearchApiError {
|
||||||
|
function: "ISearchCrawlScopeManager::IncludedInCrawlScope()",
|
||||||
|
hresult: hr,
|
||||||
|
msg: "failed to call IncludedInCrawlScope()".into(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(included == 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn commit(&self) -> Result<(), WindowSearchApiError> {
|
||||||
|
unsafe {
|
||||||
|
let vtable = self.vtable();
|
||||||
|
let fn_commit = (*vtable).SaveAll.unwrap();
|
||||||
|
let hr = fn_commit(self.i_search_crawl_scope_manager);
|
||||||
|
if hr != S_OK {
|
||||||
|
return Err(WindowSearchApiError {
|
||||||
|
function: "ISearchCrawlScopeManager::SaveAll()",
|
||||||
|
hresult: hr,
|
||||||
|
msg: "failed to commit the changes".into(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for CrawlScopeManager {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
unsafe {
|
||||||
|
CoUninitialize();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode_path<P: AsRef<Path> + ?Sized>(path: &P) -> Vec<u16> {
|
||||||
|
let mut buffer = OsString::new();
|
||||||
|
|
||||||
|
// schema
|
||||||
|
buffer.push("file:///");
|
||||||
|
buffer.push(path.as_ref().as_os_str());
|
||||||
|
|
||||||
|
osstr_to_wstr(&buffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn osstr_to_wstr<S: AsRef<OsStr> + ?Sized>(str: &S) -> Vec<u16> {
|
||||||
|
let os_str: &OsStr = str.as_ref();
|
||||||
|
let mut chars = os_str.encode_wide().collect::<Vec<u16>>();
|
||||||
|
chars.push(0 /* NUL */);
|
||||||
|
|
||||||
|
chars
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
//! Rust binding of the types and functions declared in 'searchapi.h'
|
||||||
|
|
||||||
|
#![allow(unused)]
|
||||||
|
#![allow(non_camel_case_types)]
|
||||||
|
#![allow(non_snake_case)]
|
||||||
|
#![allow(non_upper_case_globals)]
|
||||||
|
#![allow(unsafe_op_in_unsafe_fn)]
|
||||||
|
#![allow(unnecessary_transmutes)]
|
||||||
|
|
||||||
|
include!(concat!(env!("OUT_DIR"), "/searchapi_bindings.rs"));
|
||||||
|
|
||||||
|
// The bindings.rs contains a GUID type as well, we use the one provided by
|
||||||
|
// the windows_sys crate here.
|
||||||
|
use windows_sys::core::GUID as WIN_SYS_GUID;
|
||||||
|
|
||||||
|
// https://github.com/search?q=CLSID_CSearchManager+language%3AC&type=code&l=C
|
||||||
|
pub(crate) static CLSID_CSEARCH_MANAGER: WIN_SYS_GUID = WIN_SYS_GUID {
|
||||||
|
data1: 0x7d096c5f,
|
||||||
|
data2: 0xac08,
|
||||||
|
data3: 0x4f1f,
|
||||||
|
data4: [0xbe, 0xb7, 0x5c, 0x22, 0xc5, 0x17, 0xce, 0x39],
|
||||||
|
};
|
||||||
|
|
||||||
|
// https://github.com/search?q=IID_ISearchManager+language%3AC&type=code
|
||||||
|
pub(crate) static IID_ISEARCH_MANAGER: WIN_SYS_GUID = WIN_SYS_GUID {
|
||||||
|
data1: 0xAB310581,
|
||||||
|
data2: 0xac80,
|
||||||
|
data3: 0x11d1,
|
||||||
|
data4: [0x8d, 0xf3, 0x00, 0xc0, 0x4f, 0xb6, 0xef, 0x69],
|
||||||
|
};
|
||||||
@@ -3,13 +3,17 @@
|
|||||||
//! https://github.com/IRONAGE-Park/rag-sample/blob/3f0ad8c8012026cd3a7e453d08f041609426cb91/src/native/windows.rs
|
//! https://github.com/IRONAGE-Park/rag-sample/blob/3f0ad8c8012026cd3a7e453d08f041609426cb91/src/native/windows.rs
|
||||||
//! is the starting point of this implementation.
|
//! is the starting point of this implementation.
|
||||||
|
|
||||||
|
mod crawl_scope_manager;
|
||||||
|
|
||||||
use super::super::EXTENSION_ID;
|
use super::super::EXTENSION_ID;
|
||||||
use super::super::config::FileSearchConfig;
|
use super::super::config::FileSearchConfig;
|
||||||
use super::super::config::SearchBy;
|
use super::super::config::SearchBy;
|
||||||
use crate::common::document::{DataSourceReference, Document};
|
use crate::common::document::{DataSourceReference, Document};
|
||||||
use crate::extension::LOCAL_QUERY_SOURCE_TYPE;
|
use crate::extension::LOCAL_QUERY_SOURCE_TYPE;
|
||||||
use crate::extension::OnOpened;
|
use crate::extension::OnOpened;
|
||||||
use crate::util::file::get_file_icon;
|
use crate::util::file::sync_get_file_icon;
|
||||||
|
use std::borrow::Borrow;
|
||||||
|
use std::path::PathBuf;
|
||||||
use windows::{
|
use windows::{
|
||||||
Win32::System::{
|
Win32::System::{
|
||||||
Com::{CLSCTX_INPROC_SERVER, CoCreateInstance},
|
Com::{CLSCTX_INPROC_SERVER, CoCreateInstance},
|
||||||
@@ -420,7 +424,7 @@ pub(crate) async fn hits(
|
|||||||
// "file:C:/Users/desktop.ini" => "C:/Users/desktop.ini"
|
// "file:C:/Users/desktop.ini" => "C:/Users/desktop.ini"
|
||||||
let file_path = &item_url[ITEM_URL_PREFIX_LEN..];
|
let file_path = &item_url[ITEM_URL_PREFIX_LEN..];
|
||||||
|
|
||||||
let icon = get_file_icon(file_path.to_string()).await;
|
let icon = sync_get_file_icon(file_path);
|
||||||
let file_path_of_type_path = camino::Utf8Path::new(&file_path);
|
let file_path_of_type_path = camino::Utf8Path::new(&file_path);
|
||||||
let r#where = file_path_of_type_path
|
let r#where = file_path_of_type_path
|
||||||
.parent()
|
.parent()
|
||||||
@@ -468,6 +472,85 @@ pub(crate) async fn hits(
|
|||||||
Ok(hits)
|
Ok(hits)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn apply_config(config: &FileSearchConfig) -> Result<(), String> {
|
||||||
|
// To ensure Windows Search indexer index the paths we specified in the
|
||||||
|
// config, we will:
|
||||||
|
//
|
||||||
|
// 1. Add an inclusion rule for every search path to ensure indexer index
|
||||||
|
// them
|
||||||
|
// 2. For the exclude paths, we exclude them from the crawl scope if they
|
||||||
|
// were not included in the scope before we update the scope. Otherwise,
|
||||||
|
// we cannot exclude them as doing that could potentially break other
|
||||||
|
// apps (by removing the indexes they rely on).
|
||||||
|
//
|
||||||
|
// Windows APIs are pretty smart. They won't blindly add an inclusion rule if
|
||||||
|
// the path you are trying to include is already included. The same applies
|
||||||
|
// to exclusion rules as well. Since Windows APIs handle these checks for us,
|
||||||
|
// we don't need to worry about them.
|
||||||
|
|
||||||
|
use crawl_scope_manager::CrawlScopeManager;
|
||||||
|
use crawl_scope_manager::Rule;
|
||||||
|
use crawl_scope_manager::RuleMode;
|
||||||
|
use std::borrow::Cow;
|
||||||
|
|
||||||
|
/// Windows APIs need the path to contain a tailing '\'
|
||||||
|
fn add_tailing_backslash(path: &str) -> Cow<'_, str> {
|
||||||
|
if path.ends_with(r#"\"#) {
|
||||||
|
Cow::Borrowed(path)
|
||||||
|
} else {
|
||||||
|
let mut owned = path.to_string();
|
||||||
|
owned.push_str(r#"\"#);
|
||||||
|
|
||||||
|
Cow::Owned(owned)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut manager = CrawlScopeManager::new().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let search_paths = &config.search_paths;
|
||||||
|
let exclude_paths = &config.exclude_paths;
|
||||||
|
|
||||||
|
// indexes to `exclude_paths` of the paths we need to exclude
|
||||||
|
let mut paths_to_exclude: Vec<usize> = Vec::new();
|
||||||
|
for (idx, exclude_path) in exclude_paths.into_iter().enumerate() {
|
||||||
|
let exclude_path = add_tailing_backslash(&exclude_path);
|
||||||
|
let exclude_path: &str = exclude_path.borrow();
|
||||||
|
|
||||||
|
if !manager
|
||||||
|
.is_path_included(exclude_path)
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
{
|
||||||
|
paths_to_exclude.push(idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for search_path in search_paths {
|
||||||
|
let inclusion_rule = Rule {
|
||||||
|
paths: PathBuf::from(add_tailing_backslash(&search_path).into_owned()),
|
||||||
|
mode: RuleMode::Inclusion,
|
||||||
|
};
|
||||||
|
|
||||||
|
manager
|
||||||
|
.add_rule(inclusion_rule)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
|
||||||
|
for idx in paths_to_exclude {
|
||||||
|
let exclusion_rule = Rule {
|
||||||
|
paths: PathBuf::from(add_tailing_backslash(&exclude_paths[idx]).into_owned()),
|
||||||
|
mode: RuleMode::Exclusion,
|
||||||
|
};
|
||||||
|
|
||||||
|
manager
|
||||||
|
.add_rule(exclusion_rule)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
|
||||||
|
manager.commit().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
// Skip these tests in our CI, they fail with the following error
|
// Skip these tests in our CI, they fail with the following error
|
||||||
// "SQL is invalid: "0x80041820""
|
// "SQL is invalid: "0x80041820""
|
||||||
//
|
//
|
||||||
@@ -19,7 +19,7 @@ pub(crate) const PLUGIN_JSON_FILE: &str = r#"
|
|||||||
{
|
{
|
||||||
"id": "File Search",
|
"id": "File Search",
|
||||||
"name": "File Search",
|
"name": "File Search",
|
||||||
"platforms": ["macos", "windows"],
|
"platforms": ["macos", "windows", "linux"],
|
||||||
"description": "Search files on your system",
|
"description": "Search files on your system",
|
||||||
"icon": "font_Filesearch",
|
"icon": "font_Filesearch",
|
||||||
"type": "extension"
|
"type": "extension"
|
||||||
|
|||||||
@@ -3,10 +3,11 @@
|
|||||||
pub mod ai_overview;
|
pub mod ai_overview;
|
||||||
pub mod application;
|
pub mod application;
|
||||||
pub mod calculator;
|
pub mod calculator;
|
||||||
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
|
||||||
pub mod file_search;
|
pub mod file_search;
|
||||||
pub mod pizza_engine_runtime;
|
pub mod pizza_engine_runtime;
|
||||||
pub mod quick_ai_access;
|
pub mod quick_ai_access;
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
pub mod window_management;
|
||||||
|
|
||||||
use super::Extension;
|
use super::Extension;
|
||||||
use crate::SearchSourceRegistry;
|
use crate::SearchSourceRegistry;
|
||||||
@@ -15,6 +16,8 @@ use crate::extension::{
|
|||||||
ExtensionBundleIdBorrowed, PLUGIN_JSON_FILE_NAME, alter_extension_json_file,
|
ExtensionBundleIdBorrowed, PLUGIN_JSON_FILE_NAME, alter_extension_json_file,
|
||||||
};
|
};
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
|
use file_search::config::FileSearchConfig;
|
||||||
|
use file_search::implementation::apply_config as file_search_apply_config;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use tauri::{AppHandle, Manager};
|
use tauri::{AppHandle, Manager};
|
||||||
|
|
||||||
@@ -173,17 +176,26 @@ pub(crate) async fn list_built_in_extensions(
|
|||||||
.await?,
|
.await?,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
built_in_extensions.push(
|
||||||
|
load_built_in_extension(
|
||||||
|
&dir,
|
||||||
|
file_search::EXTENSION_ID,
|
||||||
|
file_search::PLUGIN_JSON_FILE,
|
||||||
|
)
|
||||||
|
.await?,
|
||||||
|
);
|
||||||
|
|
||||||
cfg_if::cfg_if! {
|
cfg_if::cfg_if! {
|
||||||
if #[cfg(any(target_os = "macos", target_os = "windows"))] {
|
if #[cfg(target_os = "macos")] {
|
||||||
built_in_extensions.push(
|
built_in_extensions.push(
|
||||||
load_built_in_extension(
|
load_built_in_extension(
|
||||||
&dir,
|
&dir,
|
||||||
file_search::EXTENSION_ID,
|
window_management::EXTENSION_ID,
|
||||||
file_search::PLUGIN_JSON_FILE,
|
window_management::PLUGIN_JSON_FILE,
|
||||||
)
|
)
|
||||||
.await?,
|
.await?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(built_in_extensions)
|
Ok(built_in_extensions)
|
||||||
@@ -212,16 +224,28 @@ pub(super) async fn init_built_in_extension(
|
|||||||
log::debug!("built-in extension [{}] initialized", extension.id);
|
log::debug!("built-in extension [{}] initialized", extension.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if extension.id == file_search::EXTENSION_ID {
|
||||||
|
let file_system_search = file_search::FileSearchExtensionSearchSource;
|
||||||
|
search_source_registry
|
||||||
|
.register_source(file_system_search)
|
||||||
|
.await;
|
||||||
|
let file_search_config = FileSearchConfig::get(tauri_app_handle);
|
||||||
|
file_search_apply_config(&file_search_config)?;
|
||||||
|
log::debug!("built-in extension [{}] initialized", extension.id);
|
||||||
|
}
|
||||||
|
|
||||||
cfg_if::cfg_if! {
|
cfg_if::cfg_if! {
|
||||||
if #[cfg(any(target_os = "macos", target_os = "windows"))] {
|
if #[cfg(target_os = "macos")] {
|
||||||
if extension.id == file_search::EXTENSION_ID {
|
if extension.id == window_management::EXTENSION_ID {
|
||||||
let file_system_search = file_search::FileSearchExtensionSearchSource;
|
let file_system_search = window_management::search_source::WindowManagementSearchSource;
|
||||||
search_source_registry
|
search_source_registry
|
||||||
.register_source(file_system_search)
|
.register_source(file_system_search)
|
||||||
.await;
|
.await;
|
||||||
log::debug!("built-in extension [{}] initialized", extension.id);
|
|
||||||
}
|
window_management::set_up_commands_hotkeys(tauri_app_handle, extension)?;
|
||||||
}
|
log::debug!("built-in extension [{}] initialized", extension.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -299,19 +323,51 @@ pub(crate) async fn enable_built_in_extension(
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if bundle_id.extension_id == file_search::EXTENSION_ID {
|
||||||
|
let file_system_search = file_search::FileSearchExtensionSearchSource;
|
||||||
|
search_source_registry_tauri_state
|
||||||
|
.register_source(file_system_search)
|
||||||
|
.await;
|
||||||
|
alter_extension_json_file(
|
||||||
|
&get_built_in_extension_directory(tauri_app_handle),
|
||||||
|
bundle_id,
|
||||||
|
update_extension,
|
||||||
|
)?;
|
||||||
|
let file_search_config = FileSearchConfig::get(tauri_app_handle);
|
||||||
|
file_search_apply_config(&file_search_config)?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
cfg_if::cfg_if! {
|
cfg_if::cfg_if! {
|
||||||
if #[cfg(any(target_os = "macos", target_os = "windows"))] {
|
if #[cfg(target_os = "macos")] {
|
||||||
if bundle_id.extension_id == file_search::EXTENSION_ID {
|
if bundle_id.extension_id == window_management::EXTENSION_ID
|
||||||
let file_system_search = file_search::FileSearchExtensionSearchSource;
|
&& bundle_id.sub_extension_id.is_none()
|
||||||
search_source_registry_tauri_state
|
{
|
||||||
.register_source(file_system_search)
|
let built_in_extension_dir = get_built_in_extension_directory(tauri_app_handle);
|
||||||
.await;
|
|
||||||
alter_extension_json_file(
|
let file_system_search = window_management::search_source::WindowManagementSearchSource;
|
||||||
&get_built_in_extension_directory(tauri_app_handle),
|
search_source_registry_tauri_state
|
||||||
bundle_id,
|
.register_source(file_system_search)
|
||||||
update_extension,
|
.await;
|
||||||
)?;
|
|
||||||
return Ok(());
|
let extension =
|
||||||
|
load_extension_from_json_file(&built_in_extension_dir, bundle_id.extension_id)?;
|
||||||
|
window_management::set_up_commands_hotkeys(tauri_app_handle, &extension)?;
|
||||||
|
|
||||||
|
alter_extension_json_file(&built_in_extension_dir, bundle_id, update_extension)?;
|
||||||
|
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
if bundle_id.extension_id == window_management::EXTENSION_ID {
|
||||||
|
if let Some(command_id) = bundle_id.sub_extension_id {
|
||||||
|
let built_in_extension_dir = get_built_in_extension_directory(tauri_app_handle);
|
||||||
|
alter_extension_json_file(&built_in_extension_dir, bundle_id, update_extension)?;
|
||||||
|
|
||||||
|
let extension =
|
||||||
|
load_extension_from_json_file(&built_in_extension_dir, bundle_id.extension_id)?;
|
||||||
|
window_management::set_up_command_hotkey(tauri_app_handle, &extension, command_id)?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -387,18 +443,44 @@ pub(crate) async fn disable_built_in_extension(
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if bundle_id.extension_id == file_search::EXTENSION_ID {
|
||||||
|
search_source_registry_tauri_state
|
||||||
|
.remove_source(bundle_id.extension_id)
|
||||||
|
.await;
|
||||||
|
alter_extension_json_file(
|
||||||
|
&get_built_in_extension_directory(tauri_app_handle),
|
||||||
|
bundle_id,
|
||||||
|
update_extension,
|
||||||
|
)?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
cfg_if::cfg_if! {
|
cfg_if::cfg_if! {
|
||||||
if #[cfg(any(target_os = "macos", target_os = "windows"))] {
|
if #[cfg(target_os = "macos")] {
|
||||||
if bundle_id.extension_id == file_search::EXTENSION_ID {
|
if bundle_id.extension_id == window_management::EXTENSION_ID
|
||||||
|
&& bundle_id.sub_extension_id.is_none()
|
||||||
|
{
|
||||||
|
let built_in_extension_dir = get_built_in_extension_directory(tauri_app_handle);
|
||||||
|
|
||||||
search_source_registry_tauri_state
|
search_source_registry_tauri_state
|
||||||
.remove_source(bundle_id.extension_id)
|
.remove_source(bundle_id.extension_id)
|
||||||
.await;
|
.await;
|
||||||
alter_extension_json_file(
|
alter_extension_json_file(&built_in_extension_dir, bundle_id, update_extension)?;
|
||||||
&get_built_in_extension_directory(tauri_app_handle),
|
|
||||||
bundle_id,
|
let extension =
|
||||||
update_extension,
|
load_extension_from_json_file(&built_in_extension_dir, bundle_id.extension_id)?;
|
||||||
)?;
|
window_management::unset_commands_hotkeys(tauri_app_handle, &extension)?;
|
||||||
return Ok(());
|
}
|
||||||
|
|
||||||
|
if bundle_id.extension_id == window_management::EXTENSION_ID {
|
||||||
|
if let Some(command_id) = bundle_id.sub_extension_id {
|
||||||
|
let built_in_extension_dir = get_built_in_extension_directory(tauri_app_handle);
|
||||||
|
alter_extension_json_file(&built_in_extension_dir, bundle_id, update_extension)?;
|
||||||
|
|
||||||
|
let extension =
|
||||||
|
load_extension_from_json_file(&built_in_extension_dir, bundle_id.extension_id)?;
|
||||||
|
window_management::unset_command_hotkey(tauri_app_handle, &extension, command_id)?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -410,12 +492,32 @@ pub(crate) fn set_built_in_extension_alias(
|
|||||||
tauri_app_handle: &AppHandle,
|
tauri_app_handle: &AppHandle,
|
||||||
bundle_id: &ExtensionBundleIdBorrowed<'_>,
|
bundle_id: &ExtensionBundleIdBorrowed<'_>,
|
||||||
alias: &str,
|
alias: &str,
|
||||||
) {
|
) -> Result<(), String> {
|
||||||
if bundle_id.extension_id == application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME {
|
if bundle_id.extension_id == application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME {
|
||||||
if let Some(app_path) = bundle_id.sub_extension_id {
|
if let Some(app_path) = bundle_id.sub_extension_id {
|
||||||
application::set_app_alias(tauri_app_handle, app_path, alias);
|
application::set_app_alias(tauri_app_handle, app_path, alias);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cfg_if::cfg_if! {
|
||||||
|
if #[cfg(target_os = "macos")] {
|
||||||
|
if bundle_id.extension_id == window_management::EXTENSION_ID
|
||||||
|
&& bundle_id.sub_extension_id.is_some()
|
||||||
|
{
|
||||||
|
let update_function = |ext: &mut Extension| {
|
||||||
|
ext.alias = Some(alias.to_string());
|
||||||
|
Ok(())
|
||||||
|
};
|
||||||
|
alter_extension_json_file(
|
||||||
|
&get_built_in_extension_directory(tauri_app_handle),
|
||||||
|
bundle_id,
|
||||||
|
update_function,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn register_built_in_extension_hotkey(
|
pub(crate) fn register_built_in_extension_hotkey(
|
||||||
@@ -428,6 +530,29 @@ pub(crate) fn register_built_in_extension_hotkey(
|
|||||||
application::register_app_hotkey(&tauri_app_handle, app_path, hotkey)?;
|
application::register_app_hotkey(&tauri_app_handle, app_path, hotkey)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cfg_if::cfg_if! {
|
||||||
|
if #[cfg(target_os = "macos")] {
|
||||||
|
let update_function = |ext: &mut Extension| {
|
||||||
|
ext.hotkey = Some(hotkey.into());
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
};
|
||||||
|
|
||||||
|
if bundle_id.extension_id == window_management::EXTENSION_ID {
|
||||||
|
if let Some(command_id) = bundle_id.sub_extension_id {
|
||||||
|
alter_extension_json_file(
|
||||||
|
&get_built_in_extension_directory(tauri_app_handle),
|
||||||
|
bundle_id,
|
||||||
|
update_function,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
window_management::register_command_hotkey(tauri_app_handle, command_id, hotkey)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -440,6 +565,35 @@ pub(crate) fn unregister_built_in_extension_hotkey(
|
|||||||
application::unregister_app_hotkey(&tauri_app_handle, app_path)?;
|
application::unregister_app_hotkey(&tauri_app_handle, app_path)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cfg_if::cfg_if! {
|
||||||
|
if #[cfg(target_os = "macos")] {
|
||||||
|
let update_function = |ext: &mut Extension| {
|
||||||
|
ext.hotkey = None;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
};
|
||||||
|
|
||||||
|
if bundle_id.extension_id == window_management::EXTENSION_ID {
|
||||||
|
if let Some(command_id) = bundle_id.sub_extension_id {
|
||||||
|
|
||||||
|
let extension = load_extension_from_json_file(
|
||||||
|
&get_built_in_extension_directory(tauri_app_handle),
|
||||||
|
bundle_id.extension_id,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
window_management::unregister_command_hotkey(tauri_app_handle, &extension, command_id)?;
|
||||||
|
alter_extension_json_file(
|
||||||
|
&get_built_in_extension_directory(tauri_app_handle),
|
||||||
|
bundle_id,
|
||||||
|
update_function,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -479,6 +633,8 @@ fn load_extension_from_json_file(
|
|||||||
Ok(extension)
|
Ok(extension)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(unused_macros)] // #[function_name::named] only used on macOS
|
||||||
|
#[function_name::named]
|
||||||
pub(crate) async fn is_built_in_extension_enabled(
|
pub(crate) async fn is_built_in_extension_enabled(
|
||||||
tauri_app_handle: &AppHandle,
|
tauri_app_handle: &AppHandle,
|
||||||
bundle_id: &ExtensionBundleIdBorrowed<'_>,
|
bundle_id: &ExtensionBundleIdBorrowed<'_>,
|
||||||
@@ -524,9 +680,17 @@ pub(crate) async fn is_built_in_extension_enabled(
|
|||||||
return Ok(extension.enabled);
|
return Ok(extension.enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if bundle_id.extension_id == file_search::EXTENSION_ID && bundle_id.sub_extension_id.is_none() {
|
||||||
|
return Ok(search_source_registry_tauri_state
|
||||||
|
.get_source(bundle_id.extension_id)
|
||||||
|
.await
|
||||||
|
.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
cfg_if::cfg_if! {
|
cfg_if::cfg_if! {
|
||||||
if #[cfg(any(target_os = "macos", target_os = "windows"))] {
|
if #[cfg(target_os = "macos")] {
|
||||||
if bundle_id.extension_id == file_search::EXTENSION_ID
|
// Window Management
|
||||||
|
if bundle_id.extension_id == window_management::EXTENSION_ID
|
||||||
&& bundle_id.sub_extension_id.is_none()
|
&& bundle_id.sub_extension_id.is_none()
|
||||||
{
|
{
|
||||||
return Ok(search_source_registry_tauri_state
|
return Ok(search_source_registry_tauri_state
|
||||||
@@ -534,6 +698,25 @@ pub(crate) async fn is_built_in_extension_enabled(
|
|||||||
.await
|
.await
|
||||||
.is_some());
|
.is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Window Management commands
|
||||||
|
if bundle_id.extension_id == window_management::EXTENSION_ID
|
||||||
|
&& let Some(command_id) = bundle_id.sub_extension_id
|
||||||
|
{
|
||||||
|
let extension = load_extension_from_json_file(
|
||||||
|
&get_built_in_extension_directory(tauri_app_handle),
|
||||||
|
bundle_id.extension_id,
|
||||||
|
)?;
|
||||||
|
let commands = extension
|
||||||
|
.commands
|
||||||
|
.expect("window management extension has commands");
|
||||||
|
|
||||||
|
let extension = commands.iter().find( |cmd| cmd.id == command_id).unwrap_or_else(|| {
|
||||||
|
panic!("function [{}()] invoked with a Window Management command that does not exist, extension ID [{}] ", function_name!(), command_id)
|
||||||
|
});
|
||||||
|
|
||||||
|
return Ok(extension.enabled);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
134
src-tauri/src/extension/built_in/window_management/actions.rs
Normal file
134
src-tauri/src/extension/built_in/window_management/actions.rs
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
#[derive(Debug, Clone, PartialEq, Copy, Hash, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub enum Action {
|
||||||
|
/// Move the window to fill left half of the screen.
|
||||||
|
TopHalf,
|
||||||
|
/// Move the window to fill bottom half of the screen.
|
||||||
|
BottomHalf,
|
||||||
|
/// Move the window to fill left half of the screen.
|
||||||
|
LeftHalf,
|
||||||
|
/// Move the window to fill right half of the screen.
|
||||||
|
RightHalf,
|
||||||
|
/// Move the window to fill center half of the screen.
|
||||||
|
CenterHalf,
|
||||||
|
|
||||||
|
/// Resize window to the top left quarter of the screen.
|
||||||
|
TopLeftQuarter,
|
||||||
|
/// Resize window to the top right quarter of the screen.
|
||||||
|
TopRightQuarter,
|
||||||
|
/// Resize window to the bottom left quarter of the screen.
|
||||||
|
BottomLeftQuarter,
|
||||||
|
/// Resize window to the bottom right quarter of the screen.
|
||||||
|
BottomRightQuarter,
|
||||||
|
|
||||||
|
/// Resize window to the top left sixth of the screen.
|
||||||
|
TopLeftSixth,
|
||||||
|
/// Resize window to the top center sixth of the screen.
|
||||||
|
TopCenterSixth,
|
||||||
|
/// Resize window to the top right sixth of the screen.
|
||||||
|
TopRightSixth,
|
||||||
|
/// Resize window to the bottom left sixth of the screen.
|
||||||
|
BottomLeftSixth,
|
||||||
|
/// Resize window to the bottom center sixth of the screen.
|
||||||
|
BottomCenterSixth,
|
||||||
|
/// Resize window to the bottom right sixth of the screen.
|
||||||
|
BottomRightSixth,
|
||||||
|
|
||||||
|
/// Resize window to the top third of the screen.
|
||||||
|
TopThird,
|
||||||
|
/// Resize window to the middle third of the screen.
|
||||||
|
MiddleThird,
|
||||||
|
/// Resize window to the bottom third of the screen.
|
||||||
|
BottomThird,
|
||||||
|
|
||||||
|
/// Center window in the screen.
|
||||||
|
Center,
|
||||||
|
|
||||||
|
/// Resize window to the first fourth of the screen.
|
||||||
|
FirstFourth,
|
||||||
|
/// Resize window to the second fourth of the screen.
|
||||||
|
SecondFourth,
|
||||||
|
/// Resize window to the third fourth of the screen.
|
||||||
|
ThirdFourth,
|
||||||
|
/// Resize window to the last fourth of the screen.
|
||||||
|
LastFourth,
|
||||||
|
|
||||||
|
/// Resize window to the first third of the screen.
|
||||||
|
FirstThird,
|
||||||
|
/// Resize window to the center third of the screen.
|
||||||
|
CenterThird,
|
||||||
|
/// Resize window to the last third of the screen.
|
||||||
|
LastThird,
|
||||||
|
|
||||||
|
/// Resize window to the first two thirds of the screen.
|
||||||
|
FirstTwoThirds,
|
||||||
|
/// Resize window to the center two thirds of the screen.
|
||||||
|
CenterTwoThirds,
|
||||||
|
/// Resize window to the last two thirds of the screen.
|
||||||
|
LastTwoThirds,
|
||||||
|
|
||||||
|
/// Resize window to the first three fourths of the screen.
|
||||||
|
FirstThreeFourths,
|
||||||
|
/// Resize window to the center three fourths of the screen.
|
||||||
|
CenterThreeFourths,
|
||||||
|
/// Resize window to the last three fourths of the screen.
|
||||||
|
LastThreeFourths,
|
||||||
|
|
||||||
|
/// Resize window to the top three fourths of the screen.
|
||||||
|
TopThreeFourths,
|
||||||
|
/// Resize window to the bottom three fourths of the screen.
|
||||||
|
BottomThreeFourths,
|
||||||
|
|
||||||
|
/// Resize window to the top two thirds of the screen.
|
||||||
|
TopTwoThirds,
|
||||||
|
/// Resize window to the bottom two thirds of the screen.
|
||||||
|
BottomTwoThirds,
|
||||||
|
/// Resize window to the top center two thirds of the screen.
|
||||||
|
TopCenterTwoThirds,
|
||||||
|
|
||||||
|
/// Resize window to the top first fourth of the screen.
|
||||||
|
TopFirstFourth,
|
||||||
|
/// Resize window to the top second fourth of the screen.
|
||||||
|
TopSecondFourth,
|
||||||
|
/// Resize window to the top third fourth of the screen.
|
||||||
|
TopThirdFourth,
|
||||||
|
/// Resize window to the top last fourth of the screen.
|
||||||
|
TopLastFourth,
|
||||||
|
|
||||||
|
/// Increase the window until it reaches the screen size.
|
||||||
|
MakeLarger,
|
||||||
|
/// Decrease the window until it reaches its minimal size.
|
||||||
|
MakeSmaller,
|
||||||
|
|
||||||
|
/// Maximize window to almost fit the screen.
|
||||||
|
AlmostMaximize,
|
||||||
|
/// Maximize window to fit the screen.
|
||||||
|
Maximize,
|
||||||
|
/// Maximize width of window to fit the screen.
|
||||||
|
MaximizeWidth,
|
||||||
|
/// Maximize height of window to fit the screen.
|
||||||
|
MaximizeHeight,
|
||||||
|
|
||||||
|
/// Move window to the top edge of the screen.
|
||||||
|
MoveUp,
|
||||||
|
/// Move window to the bottom of the screen.
|
||||||
|
MoveDown,
|
||||||
|
/// Move window to the left edge of the screen.
|
||||||
|
MoveLeft,
|
||||||
|
/// Move window to the right edge of the screen.
|
||||||
|
MoveRight,
|
||||||
|
|
||||||
|
/// Move window to the next desktop.
|
||||||
|
NextDesktop,
|
||||||
|
/// Move window to the previous desktop.
|
||||||
|
PreviousDesktop,
|
||||||
|
/// Move window to the next display.
|
||||||
|
NextDisplay,
|
||||||
|
/// Move window to the previous display.
|
||||||
|
PreviousDisplay,
|
||||||
|
|
||||||
|
/// Restore window to its last position.
|
||||||
|
Restore,
|
||||||
|
|
||||||
|
/// Toggle fullscreen mode.
|
||||||
|
ToggleFullscreen,
|
||||||
|
}
|
||||||
@@ -0,0 +1,796 @@
|
|||||||
|
//! 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 std::time::Duration;
|
||||||
|
|
||||||
|
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::CGRectIntersectsRect;
|
||||||
|
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 {
|
||||||
|
unsafe { CGRectIntersectsRect(r1, r2) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a set origin request to the `ui_element`, return once request is sent.
|
||||||
|
fn set_ui_element_origin_oneshot(
|
||||||
|
ui_element: &CFRetained<AXUIElement>,
|
||||||
|
mut origin: CGPoint,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
let ptr_to_origin = NonNull::new((&mut origin as *mut CGPoint).cast::<c_void>()).unwrap();
|
||||||
|
let pos_value = unsafe { AXValue::new(AXValueType::CGPoint, ptr_to_origin) }.unwrap();
|
||||||
|
let pos_attr = CFString::from_static_str("AXPosition");
|
||||||
|
|
||||||
|
let error = unsafe { ui_element.set_attribute_value(&pos_attr, pos_value.deref()) };
|
||||||
|
if error != AXError::Success {
|
||||||
|
return Err(Error::AXError(error));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a set size request to the `ui_element`, return once request is sent.
|
||||||
|
fn set_ui_element_size_oneshot(
|
||||||
|
ui_element: &CFRetained<AXUIElement>,
|
||||||
|
mut size: CGSize,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
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 { ui_element.set_attribute_value(&size_attr, size_value.deref()) };
|
||||||
|
if error != AXError::Success {
|
||||||
|
return Err(Error::AXError(error));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 prev_mouse_position = unsafe {
|
||||||
|
let event = CGEvent::new(None);
|
||||||
|
CGEvent::location(event.as_deref())
|
||||||
|
};
|
||||||
|
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make a slight delay to make sure the window is grabbed
|
||||||
|
std::thread::sleep(Duration::from_millis(50));
|
||||||
|
|
||||||
|
// 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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make a slight delay to finish the space transition animation
|
||||||
|
std::thread::sleep(Duration::from_millis(50));
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Cleanup
|
||||||
|
*/
|
||||||
|
unsafe {
|
||||||
|
// Let go of the window.
|
||||||
|
CGEvent::post(CGEventTapLocation::HIDEventTap, mouse_up_event.as_deref());
|
||||||
|
|
||||||
|
// Reset mouse position
|
||||||
|
let mouse_reset_event = {
|
||||||
|
CGEvent::new_mouse_event(
|
||||||
|
None,
|
||||||
|
CGEventType::MouseMoved,
|
||||||
|
prev_mouse_position,
|
||||||
|
CGMouseButton::Left,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
CGEvent::set_flags(mouse_reset_event.as_deref(), CGEventFlags(0));
|
||||||
|
CGEvent::post(
|
||||||
|
CGEventTapLocation::HIDEventTap,
|
||||||
|
mouse_reset_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.
|
||||||
|
///
|
||||||
|
/// Update: This could be wrong, but looks like Apple fixed these 2 bugs in macOS
|
||||||
|
/// 26. At least the buggy behaviors disappear in my test.
|
||||||
|
///
|
||||||
|
///
|
||||||
|
/// [^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.
|
||||||
|
///
|
||||||
|
/// This function **retries** up to `RETRY` times until the set operations
|
||||||
|
/// successfully get performed.
|
||||||
|
///
|
||||||
|
/// # Retry
|
||||||
|
///
|
||||||
|
/// Retry is added because I encountered a case where `AXUIElementSetAttributeValue()`
|
||||||
|
/// does not work in the expected way. When I execute the `NextDisplay` command
|
||||||
|
/// to move the focused window from a big display (2560x1440) to a small display
|
||||||
|
/// (1440*900), the window size could be set to 1460 sometimes. No idea if this
|
||||||
|
/// is a bug of the Accessibility APIs or due to the improper API uses. So we
|
||||||
|
/// retry for `RETRY` times at most to try our beest make it behave correctly.
|
||||||
|
pub fn set_frontmost_window_frame(frame: CGRect) -> Result<(), Error> {
|
||||||
|
const RETRY: usize = 5;
|
||||||
|
/// Sleep for 50ms as I don't want to send too many requests to the focused
|
||||||
|
/// app and WindowServer because doing that could make them busy and then
|
||||||
|
/// they won't process my set requests.
|
||||||
|
///
|
||||||
|
/// The above is simply my observation, I don't know how the messaging really
|
||||||
|
/// works under the hood.
|
||||||
|
const SLEEP: Duration = Duration::from_millis(50);
|
||||||
|
|
||||||
|
let frontmost_window = get_frontmost_window()?;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Set window origin
|
||||||
|
*/
|
||||||
|
set_ui_element_origin_oneshot(&frontmost_window, frame.origin)?;
|
||||||
|
for _ in 0..RETRY {
|
||||||
|
std::thread::sleep(SLEEP);
|
||||||
|
|
||||||
|
let current = get_ui_element_origin(&frontmost_window)?;
|
||||||
|
if current == frame.origin {
|
||||||
|
break;
|
||||||
|
} else {
|
||||||
|
set_ui_element_origin_oneshot(&frontmost_window, frame.origin)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Set window size
|
||||||
|
*/
|
||||||
|
set_ui_element_size_oneshot(&frontmost_window, frame.size)?;
|
||||||
|
for _ in 0..RETRY {
|
||||||
|
std::thread::sleep(SLEEP);
|
||||||
|
|
||||||
|
let current = get_ui_element_size(&frontmost_window)?;
|
||||||
|
// For size, we do not check if `current` has the exact same value as
|
||||||
|
// `frame.size` as I have encountered a case where I ask macOS to set
|
||||||
|
// the height to 1550, but the height gets set to 1551.
|
||||||
|
if cgsize_roughly_equal(current, frame.size, 3.0) {
|
||||||
|
break;
|
||||||
|
} else {
|
||||||
|
set_ui_element_size_oneshot(&frontmost_window, frame.size)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if `lhs` roughly equals to `rhs`. The Roughness can be controlled by
|
||||||
|
/// argument `tolerance`.
|
||||||
|
fn cgsize_roughly_equal(lhs: CGSize, rhs: CGSize, tolerance: f64) -> bool {
|
||||||
|
let width_diff = (lhs.width - rhs.width).abs();
|
||||||
|
let height_diff = (lhs.height - rhs.height).abs();
|
||||||
|
|
||||||
|
width_diff <= tolerance && height_diff <= tolerance
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_intersects_adjacent_rects_x() {
|
||||||
|
let r1 = CGRect::new(CGPoint::new(0.0, 0.0), CGSize::new(100.0, 100.0));
|
||||||
|
let r2 = CGRect::new(CGPoint::new(100.0, 0.0), CGSize::new(100.0, 100.0));
|
||||||
|
assert!(
|
||||||
|
!intersects(r1, r2),
|
||||||
|
"Adjacent rects on X should not intersect"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_intersects_adjacent_rects_y() {
|
||||||
|
let r1 = CGRect::new(CGPoint::new(0.0, 0.0), CGSize::new(100.0, 100.0));
|
||||||
|
let r2 = CGRect::new(CGPoint::new(0.0, 100.0), CGSize::new(100.0, 100.0));
|
||||||
|
assert!(
|
||||||
|
!intersects(r1, r2),
|
||||||
|
"Adjacent rects on Y should not intersect"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_intersects_overlapping_rects() {
|
||||||
|
let r1 = CGRect::new(CGPoint::new(0.0, 0.0), CGSize::new(100.0, 100.0));
|
||||||
|
let r2 = CGRect::new(CGPoint::new(50.0, 50.0), CGSize::new(100.0, 100.0));
|
||||||
|
assert!(intersects(r1, r2), "Overlapping rects should intersect");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_intersects_separate_rects() {
|
||||||
|
let r1 = CGRect::new(CGPoint::new(0.0, 0.0), CGSize::new(100.0, 100.0));
|
||||||
|
let r2 = CGRect::new(CGPoint::new(101.0, 101.0), CGSize::new(100.0, 100.0));
|
||||||
|
assert!(!intersects(r1, r2), "Separate rects should not intersect");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_intersects_contained_rect() {
|
||||||
|
let r1 = CGRect::new(CGPoint::new(0.0, 0.0), CGSize::new(100.0, 100.0));
|
||||||
|
let r2 = CGRect::new(CGPoint::new(10.0, 10.0), CGSize::new(50.0, 50.0));
|
||||||
|
assert!(intersects(r1, r2), "Contained rect should intersect");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_intersects_identical_rects() {
|
||||||
|
let r1 = CGRect::new(CGPoint::new(0.0, 0.0), CGSize::new(100.0, 100.0));
|
||||||
|
let r2 = CGRect::new(CGPoint::new(0.0, 0.0), CGSize::new(100.0, 100.0));
|
||||||
|
assert!(intersects(r1, r2), "Identical rects should intersect");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
}
|
||||||
974
src-tauri/src/extension/built_in/window_management/mod.rs
Normal file
974
src-tauri/src/extension/built_in/window_management/mod.rs
Normal file
@@ -0,0 +1,974 @@
|
|||||||
|
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";
|
||||||
|
pub(crate) const EXTENSION_NAME_LOWERCASE: &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,138 @@
|
|||||||
|
use super::EXTENSION_ID;
|
||||||
|
use super::EXTENSION_NAME_LOWERCASE;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An "extension" type extension should return all its
|
||||||
|
// sub-extensions when the query string matches its name.
|
||||||
|
// To do this, we score the extension name and take that
|
||||||
|
// into account.
|
||||||
|
if let Some(main_extension_score) =
|
||||||
|
calculate_text_similarity(&query_string_lowercase, &EXTENSION_NAME_LOWERCASE)
|
||||||
|
{
|
||||||
|
score += main_extension_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,20 +1,27 @@
|
|||||||
|
pub(crate) mod api;
|
||||||
pub(crate) mod built_in;
|
pub(crate) mod built_in;
|
||||||
pub(crate) mod third_party;
|
pub(crate) mod third_party;
|
||||||
|
pub(crate) mod view_extension;
|
||||||
|
|
||||||
use crate::common::document::ExtensionOnOpened;
|
use crate::common::document::ExtensionOnOpened;
|
||||||
use crate::common::document::ExtensionOnOpenedType;
|
use crate::common::document::ExtensionOnOpenedType;
|
||||||
use crate::common::document::OnOpened;
|
use crate::common::document::OnOpened;
|
||||||
use crate::common::register::SearchSourceRegistry;
|
use crate::common::register::SearchSourceRegistry;
|
||||||
use crate::util::platform::Platform;
|
use crate::util::platform::Platform;
|
||||||
|
use crate::util::version::COCO_VERSION;
|
||||||
|
use crate::util::version::parse_coco_semver;
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
|
use bitflags::bitflags;
|
||||||
use borrowme::{Borrow, ToOwned};
|
use borrowme::{Borrow, ToOwned};
|
||||||
use derive_more::Display;
|
use derive_more::Display;
|
||||||
use indexmap::IndexMap;
|
use indexmap::IndexMap;
|
||||||
|
use semver::Version as SemVer;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use serde_json::Value as Json;
|
use serde_json::Value as Json;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
use std::ops::Deref;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use tauri::{AppHandle, Manager};
|
use tauri::{AppHandle, Manager};
|
||||||
use third_party::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE;
|
use third_party::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE;
|
||||||
@@ -22,6 +29,7 @@ use third_party::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE;
|
|||||||
pub const LOCAL_QUERY_SOURCE_TYPE: &str = "local";
|
pub const LOCAL_QUERY_SOURCE_TYPE: &str = "local";
|
||||||
const PLUGIN_JSON_FILE_NAME: &str = "plugin.json";
|
const PLUGIN_JSON_FILE_NAME: &str = "plugin.json";
|
||||||
const ASSETS_DIRECTORY_FILE_NAME: &str = "assets";
|
const ASSETS_DIRECTORY_FILE_NAME: &str = "assets";
|
||||||
|
const PLUGIN_JSON_FIELD_MINIMUM_COCO_VERSION: &str = "minimum_coco_version";
|
||||||
|
|
||||||
fn default_true() -> bool {
|
fn default_true() -> bool {
|
||||||
true
|
true
|
||||||
@@ -37,10 +45,9 @@ pub struct Extension {
|
|||||||
name: String,
|
name: String,
|
||||||
/// ID of the developer.
|
/// ID of the developer.
|
||||||
///
|
///
|
||||||
/// * For built-in extensions, this will always be None.
|
/// * For built-in extensions, this is None.
|
||||||
/// * For third-party first-layer extensions, the on-disk plugin.json file
|
/// * For third-party main extensions, this field contains the extension developer ID.
|
||||||
/// won't contain this field, but we will set this field for them after reading them into the memory.
|
/// * For third-party sub extensions, this field is be None.
|
||||||
/// * For third-party sub extensions, this field will be None.
|
|
||||||
developer: Option<String>,
|
developer: Option<String>,
|
||||||
/// Platforms supported by this extension.
|
/// Platforms supported by this extension.
|
||||||
///
|
///
|
||||||
@@ -78,11 +85,14 @@ pub struct Extension {
|
|||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
quicklink: Option<Quicklink>,
|
quicklink: Option<Quicklink>,
|
||||||
|
|
||||||
// If this extension is of type Group or Extension, then it behaves like a
|
/*
|
||||||
// directory, i.e., it could contain sub items.
|
* If this extension is of type Group or Extension, then it behaves like a
|
||||||
|
* directory, i.e., it could contain sub items.
|
||||||
|
*/
|
||||||
commands: Option<Vec<Extension>>,
|
commands: Option<Vec<Extension>>,
|
||||||
scripts: Option<Vec<Extension>>,
|
scripts: Option<Vec<Extension>>,
|
||||||
quicklinks: Option<Vec<Extension>>,
|
quicklinks: Option<Vec<Extension>>,
|
||||||
|
views: Option<Vec<Extension>>,
|
||||||
|
|
||||||
/// The alias of the extension.
|
/// The alias of the extension.
|
||||||
///
|
///
|
||||||
@@ -103,12 +113,50 @@ pub struct Extension {
|
|||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
settings: Option<ExtensionSettings>,
|
settings: Option<ExtensionSettings>,
|
||||||
|
|
||||||
// We do not care about these fields, just take it regardless of what it is.
|
/// For View extensions, path to the HTML file/page that coco will load
|
||||||
|
/// and render. Otherwise, `None`.
|
||||||
|
///
|
||||||
|
/// It could be a path relative to the extension root directory, Coco will
|
||||||
|
/// canonicalize it in that case.
|
||||||
|
page: Option<String>,
|
||||||
|
|
||||||
|
ui: Option<ViewExtensionUISettings>,
|
||||||
|
|
||||||
|
/// Permission that this extension requires.
|
||||||
|
permission: Option<ExtensionPermission>,
|
||||||
|
|
||||||
|
/// The version of Coco app that this extension requires.
|
||||||
|
///
|
||||||
|
/// If not set, then this extension is compatible with all versions of Coco app.
|
||||||
|
///
|
||||||
|
/// It is only for third-party extensions. Built-in extensions should always
|
||||||
|
/// set this field to `None`.
|
||||||
|
#[serde(deserialize_with = "deserialize_coco_semver")]
|
||||||
|
#[serde(default)] // None if this field is missing
|
||||||
|
minimum_coco_version: Option<SemVer>,
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The following fields are currently useless to us but are needed by our
|
||||||
|
* extension store.
|
||||||
|
*
|
||||||
|
* Since we do not use them, just accept them regardless of what they are.
|
||||||
|
*/
|
||||||
screenshots: Option<Json>,
|
screenshots: Option<Json>,
|
||||||
url: Option<Json>,
|
url: Option<Json>,
|
||||||
version: Option<Json>,
|
version: Option<Json>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Settings that control the built-in UI Components
|
||||||
|
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
|
||||||
|
pub(crate) struct ViewExtensionUISettings {
|
||||||
|
/// Show the search bar
|
||||||
|
search_bar: bool,
|
||||||
|
/// Show the filter bar
|
||||||
|
filter_bar: bool,
|
||||||
|
/// Show the footer
|
||||||
|
footer: bool,
|
||||||
|
}
|
||||||
|
|
||||||
/// Bundle ID uniquely identifies an extension.
|
/// Bundle ID uniquely identifies an extension.
|
||||||
#[derive(Debug, Deserialize, Serialize, PartialEq, Clone)]
|
#[derive(Debug, Deserialize, Serialize, PartialEq, Clone)]
|
||||||
pub(crate) struct ExtensionBundleId {
|
pub(crate) struct ExtensionBundleId {
|
||||||
@@ -176,6 +224,7 @@ impl Extension {
|
|||||||
/// `None` if it cannot be opened.
|
/// `None` if it cannot be opened.
|
||||||
pub(crate) fn on_opened(&self) -> Option<OnOpened> {
|
pub(crate) fn on_opened(&self) -> Option<OnOpened> {
|
||||||
let settings = self.settings.clone();
|
let settings = self.settings.clone();
|
||||||
|
let permission = self.permission.clone();
|
||||||
|
|
||||||
match self.r#type {
|
match self.r#type {
|
||||||
// This function, at the time of writing this comment, is primarily
|
// This function, at the time of writing this comment, is primarily
|
||||||
@@ -213,7 +262,11 @@ impl Extension {
|
|||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
let extension_on_opened = ExtensionOnOpened { ty, settings };
|
let extension_on_opened = ExtensionOnOpened {
|
||||||
|
ty,
|
||||||
|
settings,
|
||||||
|
permission,
|
||||||
|
};
|
||||||
|
|
||||||
Some(OnOpened::Extension(extension_on_opened))
|
Some(OnOpened::Extension(extension_on_opened))
|
||||||
}
|
}
|
||||||
@@ -229,12 +282,42 @@ impl Extension {
|
|||||||
open_with: quicklink.open_with,
|
open_with: quicklink.open_with,
|
||||||
};
|
};
|
||||||
|
|
||||||
let extension_on_opened = ExtensionOnOpened { ty, settings };
|
let extension_on_opened = ExtensionOnOpened {
|
||||||
|
ty,
|
||||||
|
settings,
|
||||||
|
permission,
|
||||||
|
};
|
||||||
|
|
||||||
Some(OnOpened::Extension(extension_on_opened))
|
Some(OnOpened::Extension(extension_on_opened))
|
||||||
}
|
}
|
||||||
ExtensionType::Script => todo!("not supported yet"),
|
ExtensionType::Script => todo!("not supported yet"),
|
||||||
ExtensionType::Setting => todo!("not supported yet"),
|
ExtensionType::Setting => todo!("not supported yet"),
|
||||||
|
ExtensionType::View => {
|
||||||
|
let name = self.name.clone();
|
||||||
|
let icon = self.icon.clone();
|
||||||
|
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 ui = self.ui.clone();
|
||||||
|
|
||||||
|
let extension_on_opened_type = ExtensionOnOpenedType::View {
|
||||||
|
name,
|
||||||
|
icon,
|
||||||
|
page,
|
||||||
|
ui,
|
||||||
|
};
|
||||||
|
let extension_on_opened = ExtensionOnOpened {
|
||||||
|
ty: extension_on_opened_type,
|
||||||
|
settings,
|
||||||
|
permission,
|
||||||
|
};
|
||||||
|
let on_opened = OnOpened::Extension(extension_on_opened);
|
||||||
|
|
||||||
|
Some(on_opened)
|
||||||
|
}
|
||||||
|
ExtensionType::Unknown => {
|
||||||
|
unreachable!("Extensions of type [Unknown] should never be opened")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,6 +341,11 @@ impl Extension {
|
|||||||
return Some(sub_ext);
|
return Some(sub_ext);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if let Some(ref views) = self.views {
|
||||||
|
if let Some(sub_ext) = views.iter().find(|view| view.id == sub_extension_id) {
|
||||||
|
return Some(sub_ext);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
@@ -288,6 +376,11 @@ impl Extension {
|
|||||||
return Some(sub_ext);
|
return Some(sub_ext);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if let Some(ref mut views) = self.views {
|
||||||
|
if let Some(sub_ext) = views.iter_mut().find(|view| view.id == sub_extension_id) {
|
||||||
|
return Some(sub_ext);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
@@ -299,6 +392,26 @@ impl Extension {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Deserialize Coco SemVer from a string.
|
||||||
|
///
|
||||||
|
/// This function adapts `parse_coco_semver` to work with serde's `deserialize_with`
|
||||||
|
/// attribute.
|
||||||
|
fn deserialize_coco_semver<'de, D>(deserializer: D) -> Result<Option<SemVer>, D::Error>
|
||||||
|
where
|
||||||
|
D: serde::Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let version_str: Option<String> = Option::deserialize(deserializer)?;
|
||||||
|
let Some(version_str) = version_str else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(semver) = parse_coco_semver(&version_str) else {
|
||||||
|
return Err(serde::de::Error::custom("version string format is invalid"));
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Some(semver))
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
|
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
|
||||||
pub(crate) struct CommandAction {
|
pub(crate) struct CommandAction {
|
||||||
pub(crate) exec: String,
|
pub(crate) exec: String,
|
||||||
@@ -365,7 +478,7 @@ impl QuicklinkLink {
|
|||||||
/// if any.
|
/// if any.
|
||||||
pub(crate) fn concatenate_url(
|
pub(crate) fn concatenate_url(
|
||||||
&self,
|
&self,
|
||||||
user_supplied_args: &Option<HashMap<String, String>>,
|
user_supplied_args: &Option<HashMap<String, Json>>,
|
||||||
) -> String {
|
) -> String {
|
||||||
let mut out = String::new();
|
let mut out = String::new();
|
||||||
for component in self.components.iter() {
|
for component in self.components.iter() {
|
||||||
@@ -377,20 +490,23 @@ impl QuicklinkLink {
|
|||||||
argument_name,
|
argument_name,
|
||||||
default,
|
default,
|
||||||
} => {
|
} => {
|
||||||
let opt_argument_value = {
|
let opt_argument_value: Option<&str> = {
|
||||||
let user_supplied_arg = user_supplied_args
|
let user_supplied_arg = user_supplied_args
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|map| map.get(argument_name.as_str()));
|
.and_then(|map| map.get(argument_name.as_str()));
|
||||||
|
|
||||||
if user_supplied_arg.is_some() {
|
if user_supplied_arg.is_some() {
|
||||||
user_supplied_arg
|
user_supplied_arg.map(|json| {
|
||||||
|
json.as_str()
|
||||||
|
.expect("quicklink should provide string arguments")
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
default.as_ref()
|
default.as_deref()
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let argument_value_str = match opt_argument_value {
|
let argument_value_str = match opt_argument_value {
|
||||||
Some(str) => str.as_str(),
|
Some(str) => str,
|
||||||
// None => an empty string
|
// None => an empty string
|
||||||
None => "",
|
None => "",
|
||||||
};
|
};
|
||||||
@@ -497,6 +613,12 @@ pub enum ExtensionType {
|
|||||||
Calculator,
|
Calculator,
|
||||||
#[display("AI Extension")]
|
#[display("AI Extension")]
|
||||||
AiExtension,
|
AiExtension,
|
||||||
|
#[display("View")]
|
||||||
|
View,
|
||||||
|
/// Add this variant for better compatibility: Future versions of Coco may
|
||||||
|
/// add new extension types that older versions of Coco are not aware of.
|
||||||
|
#[display("Unknown")]
|
||||||
|
Unknown,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ExtensionType {
|
impl ExtensionType {
|
||||||
@@ -528,6 +650,9 @@ fn filter_out_extensions(
|
|||||||
if let Some(ref mut quicklinks) = extension.quicklinks {
|
if let Some(ref mut quicklinks) = extension.quicklinks {
|
||||||
quicklinks.retain(|link| link.enabled);
|
quicklinks.retain(|link| link.enabled);
|
||||||
}
|
}
|
||||||
|
if let Some(ref mut views) = extension.views {
|
||||||
|
views.retain(|link| link.enabled);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -556,6 +681,9 @@ fn filter_out_extensions(
|
|||||||
if let Some(ref mut quicklinks) = extension.quicklinks {
|
if let Some(ref mut quicklinks) = extension.quicklinks {
|
||||||
quicklinks.retain(|link| link.r#type == extension_type);
|
quicklinks.retain(|link| link.r#type == extension_type);
|
||||||
}
|
}
|
||||||
|
if let Some(ref mut views) = extension.views {
|
||||||
|
views.retain(|link| link.r#type == extension_type);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -606,6 +734,9 @@ fn filter_out_extensions(
|
|||||||
if let Some(ref mut quicklinks) = extension.quicklinks {
|
if let Some(ref mut quicklinks) = extension.quicklinks {
|
||||||
quicklinks.retain(&match_closure);
|
quicklinks.retain(&match_closure);
|
||||||
}
|
}
|
||||||
|
if let Some(ref mut views) = extension.views {
|
||||||
|
views.retain(&match_closure);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -735,6 +866,22 @@ pub(crate) async fn init_extensions(tauri_app_handle: &AppHandle) -> Result<(),
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Is `extension` compatible with the current running Coco app?
|
||||||
|
///
|
||||||
|
/// It is defined as a tauri command rather than an associated function because
|
||||||
|
/// it will be used in frontend code as well.
|
||||||
|
///
|
||||||
|
/// Async tauri commands are required to return `Result<T, E>`, this function
|
||||||
|
/// only needs to return a boolean, so it is not marked async.
|
||||||
|
#[tauri::command]
|
||||||
|
pub(crate) fn is_extension_compatible(extension: Extension) -> bool {
|
||||||
|
let Some(ref minimum_coco_version) = extension.minimum_coco_version else {
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
COCO_VERSION.deref() >= minimum_coco_version
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub(crate) async fn enable_extension(
|
pub(crate) async fn enable_extension(
|
||||||
tauri_app_handle: AppHandle,
|
tauri_app_handle: AppHandle,
|
||||||
@@ -773,7 +920,7 @@ pub(crate) async fn set_extension_alias(
|
|||||||
let bundle_id_borrowed = bundle_id.borrow();
|
let bundle_id_borrowed = bundle_id.borrow();
|
||||||
|
|
||||||
if built_in::is_extension_built_in(&bundle_id_borrowed) {
|
if built_in::is_extension_built_in(&bundle_id_borrowed) {
|
||||||
built_in::set_built_in_extension_alias(&tauri_app_handle, &bundle_id_borrowed, &alias);
|
built_in::set_built_in_extension_alias(&tauri_app_handle, &bundle_id_borrowed, &alias)?;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
third_party::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE.get().expect("global third party search source not set, looks like init_extensions() has not been executed").set_extension_alias(&tauri_app_handle, &bundle_id_borrowed, &alias).await
|
third_party::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE.get().expect("global third party search source not set, looks like init_extensions() has not been executed").set_extension_alias(&tauri_app_handle, &bundle_id_borrowed, &alias).await
|
||||||
@@ -842,6 +989,13 @@ pub(crate) fn canonicalize_relative_icon_path(
|
|||||||
let icon_path = Path::new(icon_str);
|
let icon_path = Path::new(icon_str);
|
||||||
|
|
||||||
if icon_path.is_relative() {
|
if icon_path.is_relative() {
|
||||||
|
// If we enter this if statement, then there are 2 possible cases:
|
||||||
|
//
|
||||||
|
// 1. icon_path is a font class code, e.g., "font_coco"
|
||||||
|
// 2. icon_path is a indeed a relative path
|
||||||
|
//
|
||||||
|
// We distinguish between these 2 cases by checking if `absolute_icon_path` exists
|
||||||
|
|
||||||
let absolute_icon_path = {
|
let absolute_icon_path = {
|
||||||
let mut assets_directory = extension_dir.join(ASSETS_DIRECTORY_FILE_NAME);
|
let mut assets_directory = extension_dir.join(ASSETS_DIRECTORY_FILE_NAME);
|
||||||
assets_directory.push(icon_path);
|
assets_directory.push(icon_path);
|
||||||
@@ -880,6 +1034,63 @@ 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");
|
||||||
|
|
||||||
|
// Skip HTTP links
|
||||||
|
if let Ok(url) = url::Url::parse(page)
|
||||||
|
&& ["http", "https"].contains(&url.scheme())
|
||||||
|
{
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let page_path = Path::new(page);
|
||||||
|
|
||||||
|
if page_path.is_relative() {
|
||||||
|
let absolute_page_path = extension_dir.join(page_path);
|
||||||
|
|
||||||
|
if absolute_page_path.try_exists().map_err(|e| e.to_string())? {
|
||||||
|
extension.page = Some(
|
||||||
|
absolute_page_path
|
||||||
|
.into_os_string()
|
||||||
|
.into_string()
|
||||||
|
.expect("path should be UTF-8 encoded"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
if extension.r#type == ExtensionType::View {
|
||||||
|
_canonicalize_view_extension_page_path(extension_dir, extension)?;
|
||||||
|
} else if extension.r#type.contains_sub_items()
|
||||||
|
&& let Some(ref mut views) = extension.views
|
||||||
|
{
|
||||||
|
for view in views {
|
||||||
|
_canonicalize_view_extension_page_path(extension_dir, view)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -932,6 +1143,14 @@ fn alter_extension_json_file(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Search in views
|
||||||
|
if let Some(ref mut views) = root_extension.views {
|
||||||
|
if let Some(view) = views.iter_mut().find(|v| v.id == sub_extension_id) {
|
||||||
|
how(view)?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Err(format!(
|
Err(format!(
|
||||||
"extension [{:?}] not found in {:?}",
|
"extension [{:?}] not found in {:?}",
|
||||||
bundle_id, root_extension
|
bundle_id, root_extension
|
||||||
@@ -1111,6 +1330,119 @@ pub(crate) struct ExtensionSettings {
|
|||||||
pub(crate) hide_before_open: Option<bool>,
|
pub(crate) hide_before_open: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub(crate) struct ExtensionPermission {
|
||||||
|
fs: Option<Vec<ExtensionFileSystemPermission>>,
|
||||||
|
http: Option<Vec<ExtensionHttpPermission>>,
|
||||||
|
api: Option<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub(crate) struct ExtensionFileSystemPermission {
|
||||||
|
pub(crate) path: String,
|
||||||
|
pub(crate) access: FileSystemAccess,
|
||||||
|
}
|
||||||
|
|
||||||
|
bitflags! {
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
pub(crate) struct FileSystemAccess: u8 {
|
||||||
|
const READ = 0b00000001;
|
||||||
|
const WRITE = 0b00000010;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Serialize for FileSystemAccess {
|
||||||
|
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||||
|
where
|
||||||
|
S: serde::Serializer,
|
||||||
|
{
|
||||||
|
let mut access_vec = Vec::new();
|
||||||
|
if self.contains(FileSystemAccess::READ) {
|
||||||
|
access_vec.push("read");
|
||||||
|
}
|
||||||
|
if self.contains(FileSystemAccess::WRITE) {
|
||||||
|
access_vec.push("write");
|
||||||
|
}
|
||||||
|
access_vec.serialize(serializer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> Deserialize<'de> for FileSystemAccess {
|
||||||
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||||
|
where
|
||||||
|
D: serde::Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let access_vec: Vec<String> = Vec::deserialize(deserializer)?;
|
||||||
|
let mut access = FileSystemAccess::empty();
|
||||||
|
|
||||||
|
for access_type in access_vec {
|
||||||
|
match access_type.as_str() {
|
||||||
|
"read" => access |= FileSystemAccess::READ,
|
||||||
|
"write" => access |= FileSystemAccess::WRITE,
|
||||||
|
_ => {
|
||||||
|
return Err(serde::de::Error::unknown_variant(
|
||||||
|
access_type.as_str(),
|
||||||
|
&["read", "write"],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(access)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub(crate) struct ExtensionHttpPermission {
|
||||||
|
pub(crate) host: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculates a similarity score between a query and a text, aiming for a [0, 1] range.
|
||||||
|
/// Assumes query and text are already lowercased.
|
||||||
|
///
|
||||||
|
/// Used in extension_to_hit().
|
||||||
|
fn calculate_text_similarity(query: &str, text: &str) -> Option<f64> {
|
||||||
|
if query.is_empty() || text.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
if text == query {
|
||||||
|
return Some(1.0); // Perfect match
|
||||||
|
}
|
||||||
|
|
||||||
|
let query_len = query.len() as f64;
|
||||||
|
let text_len = text.len() as f64;
|
||||||
|
let ratio = query_len / text_len;
|
||||||
|
let mut score: f64 = 0.0;
|
||||||
|
|
||||||
|
// Case 1: Text starts with the query (prefix match)
|
||||||
|
// Score: base 0.5, bonus up to 0.4 for how much of `text` is covered by `query`. Max 0.9.
|
||||||
|
if text.starts_with(query) {
|
||||||
|
score = score.max(0.5 + 0.4 * ratio);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Case 2: Text contains the query (substring match, not necessarily prefix)
|
||||||
|
// Score: base 0.3, bonus up to 0.3. Max 0.6.
|
||||||
|
// `score.max` ensures that if it's both a prefix and contains, the higher score (prefix) is taken.
|
||||||
|
if text.contains(query) {
|
||||||
|
score = score.max(0.3 + 0.3 * ratio);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Case 3: Fallback for "all query characters exist in text" (order-independent)
|
||||||
|
if score < 0.2 {
|
||||||
|
if query.chars().all(|c_q| text.contains(c_q)) {
|
||||||
|
score = score.max(0.15); // Fixed low score for this weaker match type
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if score > 0.0 {
|
||||||
|
// Cap non-perfect matches slightly below 1.0 to make perfect (1.0) distinct.
|
||||||
|
Some(score.min(0.95))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -1509,7 +1841,7 @@ mod tests {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
let mut user_args = HashMap::new();
|
let mut user_args = HashMap::new();
|
||||||
user_args.insert("other_param".to_string(), "value".to_string());
|
user_args.insert("other_param".to_string(), Json::String("value".to_string()));
|
||||||
let result = link.concatenate_url(&Some(user_args));
|
let result = link.concatenate_url(&Some(user_args));
|
||||||
assert_eq!(result, "https://www.google.com/search?q=");
|
assert_eq!(result, "https://www.google.com/search?q=");
|
||||||
}
|
}
|
||||||
@@ -1546,7 +1878,7 @@ mod tests {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
let mut user_args = HashMap::new();
|
let mut user_args = HashMap::new();
|
||||||
user_args.insert("other_param".to_string(), "value".to_string());
|
user_args.insert("other_param".to_string(), Json::String("value".to_string()));
|
||||||
let result = link.concatenate_url(&Some(user_args));
|
let result = link.concatenate_url(&Some(user_args));
|
||||||
assert_eq!(result, "https://www.google.com/search?q=rust");
|
assert_eq!(result, "https://www.google.com/search?q=rust");
|
||||||
}
|
}
|
||||||
@@ -1569,7 +1901,7 @@ mod tests {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
let mut user_args = HashMap::new();
|
let mut user_args = HashMap::new();
|
||||||
user_args.insert("query".to_string(), "python".to_string());
|
user_args.insert("query".to_string(), Json::String("python".to_string()));
|
||||||
let result = link.concatenate_url(&Some(user_args));
|
let result = link.concatenate_url(&Some(user_args));
|
||||||
assert_eq!(result, "https://www.google.com/search?q=python");
|
assert_eq!(result, "https://www.google.com/search?q=python");
|
||||||
}
|
}
|
||||||
@@ -1581,4 +1913,234 @@ mod tests {
|
|||||||
let result = link.concatenate_url(&None);
|
let result = link.concatenate_url(&None);
|
||||||
assert_eq!(result, "");
|
assert_eq!(result, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper function for approximate floating point comparison
|
||||||
|
fn approx_eq(a: f64, b: f64) -> bool {
|
||||||
|
(a - b).abs() < 1e-10
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_empty_strings() {
|
||||||
|
assert_eq!(calculate_text_similarity("", "text"), None);
|
||||||
|
assert_eq!(calculate_text_similarity("query", ""), None);
|
||||||
|
assert_eq!(calculate_text_similarity("", ""), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_perfect_match() {
|
||||||
|
assert_eq!(calculate_text_similarity("text", "text"), Some(1.0));
|
||||||
|
assert_eq!(calculate_text_similarity("a", "a"), Some(1.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_prefix_match() {
|
||||||
|
// For "te" and "text":
|
||||||
|
// score = 0.5 + 0.4 * (2/4) = 0.5 + 0.2 = 0.7
|
||||||
|
let score = calculate_text_similarity("te", "text").unwrap();
|
||||||
|
assert!(approx_eq(score, 0.7));
|
||||||
|
|
||||||
|
// For "tex" and "text":
|
||||||
|
// score = 0.5 + 0.4 * (3/4) = 0.5 + 0.3 = 0.8
|
||||||
|
let score = calculate_text_similarity("tex", "text").unwrap();
|
||||||
|
assert!(approx_eq(score, 0.8));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_substring_match() {
|
||||||
|
// For "ex" and "text":
|
||||||
|
// score = 0.3 + 0.3 * (2/4) = 0.3 + 0.15 = 0.45
|
||||||
|
let score = calculate_text_similarity("ex", "text").unwrap();
|
||||||
|
assert!(approx_eq(score, 0.45));
|
||||||
|
|
||||||
|
// Prefix should score higher than substring
|
||||||
|
assert!(
|
||||||
|
calculate_text_similarity("te", "text").unwrap()
|
||||||
|
> calculate_text_similarity("ex", "text").unwrap()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_character_presence() {
|
||||||
|
// Characters present but not in sequence
|
||||||
|
// "tac" in "contact" - not a substring, but all chars exist
|
||||||
|
let score = calculate_text_similarity("tac", "contact").unwrap();
|
||||||
|
assert!(approx_eq(0.3 + 0.3 * (3.0 / 7.0), score));
|
||||||
|
|
||||||
|
assert!(calculate_text_similarity("ac", "contact").is_some());
|
||||||
|
|
||||||
|
// Should not apply if some characters are missing
|
||||||
|
assert_eq!(calculate_text_similarity("xyz", "contact"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_combined_scenarios() {
|
||||||
|
// Test that character presence fallback doesn't override higher scores
|
||||||
|
// "tex" is a prefix of "text" with score 0.8
|
||||||
|
let score = calculate_text_similarity("tex", "text").unwrap();
|
||||||
|
assert!(approx_eq(score, 0.8));
|
||||||
|
|
||||||
|
// Test a case where the characters exist but it's already a substring
|
||||||
|
// "act" is a substring of "contact" with score > 0.2, so fallback won't apply
|
||||||
|
let expected_score = 0.3 + 0.3 * (3.0 / 7.0);
|
||||||
|
let actual_score = calculate_text_similarity("act", "contact").unwrap();
|
||||||
|
assert!(approx_eq(actual_score, expected_score));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_no_similarity() {
|
||||||
|
assert_eq!(calculate_text_similarity("xyz", "test"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_score_capping() {
|
||||||
|
// Use a long query that's a prefix of a slightly longer text
|
||||||
|
let long_text = "abcdefghijklmnopqrstuvwxyz";
|
||||||
|
let long_prefix = "abcdefghijklmnopqrstuvwxy"; // All but last letter
|
||||||
|
|
||||||
|
// Expected score would be 0.5 + 0.4 * (25/26) = 0.5 + 0.385 = 0.885
|
||||||
|
let expected_score = 0.5 + 0.4 * (25.0 / 26.0);
|
||||||
|
let actual_score = calculate_text_similarity(long_prefix, long_text).unwrap();
|
||||||
|
assert!(approx_eq(actual_score, expected_score));
|
||||||
|
|
||||||
|
// Verify that non-perfect matches are capped at 0.95
|
||||||
|
assert!(calculate_text_similarity("almost", "almost perfect").unwrap() <= 0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_filesystem_access_serialize_empty() {
|
||||||
|
let access = FileSystemAccess::empty();
|
||||||
|
let serialized = serde_json::to_string(&access).unwrap();
|
||||||
|
assert_eq!(serialized, "[]");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_filesystem_access_serialize_read_only() {
|
||||||
|
let access = FileSystemAccess::READ;
|
||||||
|
let serialized = serde_json::to_string(&access).unwrap();
|
||||||
|
assert_eq!(serialized, r#"["read"]"#);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_filesystem_access_serialize_write_only() {
|
||||||
|
let access = FileSystemAccess::WRITE;
|
||||||
|
let serialized = serde_json::to_string(&access).unwrap();
|
||||||
|
assert_eq!(serialized, r#"["write"]"#);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_filesystem_access_serialize_read_write() {
|
||||||
|
let access = FileSystemAccess::READ | FileSystemAccess::WRITE;
|
||||||
|
let serialized = serde_json::to_string(&access).unwrap();
|
||||||
|
// The order should be consistent based on our implementation (read first, then write)
|
||||||
|
assert_eq!(serialized, r#"["read","write"]"#);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_filesystem_access_deserialize_empty() {
|
||||||
|
let json = "[]";
|
||||||
|
let access: FileSystemAccess = serde_json::from_str(json).unwrap();
|
||||||
|
assert_eq!(access, FileSystemAccess::empty());
|
||||||
|
assert!(!access.contains(FileSystemAccess::READ));
|
||||||
|
assert!(!access.contains(FileSystemAccess::WRITE));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_filesystem_access_deserialize_read_only() {
|
||||||
|
let json = r#"["read"]"#;
|
||||||
|
let access: FileSystemAccess = serde_json::from_str(json).unwrap();
|
||||||
|
assert_eq!(access, FileSystemAccess::READ);
|
||||||
|
assert!(access.contains(FileSystemAccess::READ));
|
||||||
|
assert!(!access.contains(FileSystemAccess::WRITE));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_filesystem_access_deserialize_write_only() {
|
||||||
|
let json = r#"["write"]"#;
|
||||||
|
let access: FileSystemAccess = serde_json::from_str(json).unwrap();
|
||||||
|
assert_eq!(access, FileSystemAccess::WRITE);
|
||||||
|
assert!(!access.contains(FileSystemAccess::READ));
|
||||||
|
assert!(access.contains(FileSystemAccess::WRITE));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_filesystem_access_deserialize_read_write() {
|
||||||
|
let json = r#"["read", "write"]"#;
|
||||||
|
let access: FileSystemAccess = serde_json::from_str(json).unwrap();
|
||||||
|
assert_eq!(access, FileSystemAccess::READ | FileSystemAccess::WRITE);
|
||||||
|
assert!(access.contains(FileSystemAccess::READ));
|
||||||
|
assert!(access.contains(FileSystemAccess::WRITE));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_filesystem_access_deserialize_write_read_order() {
|
||||||
|
// Test that order doesn't matter during deserialization
|
||||||
|
let json = r#"["write", "read"]"#;
|
||||||
|
let access: FileSystemAccess = serde_json::from_str(json).unwrap();
|
||||||
|
assert_eq!(access, FileSystemAccess::READ | FileSystemAccess::WRITE);
|
||||||
|
assert!(access.contains(FileSystemAccess::READ));
|
||||||
|
assert!(access.contains(FileSystemAccess::WRITE));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_filesystem_access_deserialize_duplicate_values() {
|
||||||
|
// Test that duplicate values don't cause issues
|
||||||
|
let json = r#"["read", "read", "write"]"#;
|
||||||
|
let access: FileSystemAccess = serde_json::from_str(json).unwrap();
|
||||||
|
assert_eq!(access, FileSystemAccess::READ | FileSystemAccess::WRITE);
|
||||||
|
assert!(access.contains(FileSystemAccess::READ));
|
||||||
|
assert!(access.contains(FileSystemAccess::WRITE));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_filesystem_access_deserialize_invalid_value() {
|
||||||
|
let json = r#"["invalid"]"#;
|
||||||
|
let result: Result<FileSystemAccess, _> = serde_json::from_str(json);
|
||||||
|
assert!(result.is_err());
|
||||||
|
|
||||||
|
let error_msg = result.unwrap_err().to_string();
|
||||||
|
assert!(error_msg.contains("invalid"));
|
||||||
|
assert!(error_msg.contains("read") && error_msg.contains("write"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_filesystem_access_deserialize_mixed_valid_invalid() {
|
||||||
|
let json = r#"["read", "invalid", "write"]"#;
|
||||||
|
let result: Result<FileSystemAccess, _> = serde_json::from_str(json);
|
||||||
|
assert!(result.is_err());
|
||||||
|
|
||||||
|
let error_msg = result.unwrap_err().to_string();
|
||||||
|
assert!(error_msg.contains("invalid"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_filesystem_access_round_trip_empty() {
|
||||||
|
let original = FileSystemAccess::empty();
|
||||||
|
let serialized = serde_json::to_string(&original).unwrap();
|
||||||
|
let deserialized: FileSystemAccess = serde_json::from_str(&serialized).unwrap();
|
||||||
|
assert_eq!(original, deserialized);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_filesystem_access_round_trip_read() {
|
||||||
|
let original = FileSystemAccess::READ;
|
||||||
|
let serialized = serde_json::to_string(&original).unwrap();
|
||||||
|
let deserialized: FileSystemAccess = serde_json::from_str(&serialized).unwrap();
|
||||||
|
assert_eq!(original, deserialized);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_filesystem_access_round_trip_write() {
|
||||||
|
let original = FileSystemAccess::WRITE;
|
||||||
|
let serialized = serde_json::to_string(&original).unwrap();
|
||||||
|
let deserialized: FileSystemAccess = serde_json::from_str(&serialized).unwrap();
|
||||||
|
assert_eq!(original, deserialized);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_filesystem_access_round_trip_read_write() {
|
||||||
|
let original = FileSystemAccess::READ | FileSystemAccess::WRITE;
|
||||||
|
let serialized = serde_json::to_string(&original).unwrap();
|
||||||
|
let deserialized: FileSystemAccess = serde_json::from_str(&serialized).unwrap();
|
||||||
|
assert_eq!(original, deserialized);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
109
src-tauri/src/extension/third_party/check.rs
vendored
109
src-tauri/src/extension/third_party/check.rs
vendored
@@ -14,6 +14,7 @@
|
|||||||
|
|
||||||
use crate::extension::Extension;
|
use crate::extension::Extension;
|
||||||
use crate::extension::ExtensionType;
|
use crate::extension::ExtensionType;
|
||||||
|
use crate::extension::PLUGIN_JSON_FIELD_MINIMUM_COCO_VERSION;
|
||||||
use crate::util::platform::Platform;
|
use crate::util::platform::Platform;
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
|
||||||
@@ -47,7 +48,11 @@ pub(crate) fn general_check(extension: &Extension) -> Result<(), String> {
|
|||||||
Some(ref v) => v.as_slice(),
|
Some(ref v) => v.as_slice(),
|
||||||
None => &[],
|
None => &[],
|
||||||
};
|
};
|
||||||
let sub_extensions = [commands, scripts, quicklinks].concat();
|
let views = match extension.views {
|
||||||
|
Some(ref v) => v.as_slice(),
|
||||||
|
None => &[],
|
||||||
|
};
|
||||||
|
let sub_extensions = [commands, scripts, quicklinks, views].concat();
|
||||||
let mut sub_extension_ids = HashSet::new();
|
let mut sub_extension_ids = HashSet::new();
|
||||||
|
|
||||||
for sub_extension in sub_extensions.iter() {
|
for sub_extension in sub_extensions.iter() {
|
||||||
@@ -93,7 +98,10 @@ fn check_main_extension_only(extension: &Extension) -> Result<(), String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if extension.commands.is_some() || extension.scripts.is_some() || extension.quicklinks.is_some()
|
if extension.commands.is_some()
|
||||||
|
|| extension.scripts.is_some()
|
||||||
|
|| extension.quicklinks.is_some()
|
||||||
|
|| extension.views.is_some()
|
||||||
{
|
{
|
||||||
if extension.r#type != ExtensionType::Group && extension.r#type != ExtensionType::Extension
|
if extension.r#type != ExtensionType::Group && extension.r#type != ExtensionType::Extension
|
||||||
{
|
{
|
||||||
@@ -134,9 +142,10 @@ fn check_sub_extension_only(
|
|||||||
if sub_extension.commands.is_some()
|
if sub_extension.commands.is_some()
|
||||||
|| sub_extension.scripts.is_some()
|
|| sub_extension.scripts.is_some()
|
||||||
|| sub_extension.quicklinks.is_some()
|
|| sub_extension.quicklinks.is_some()
|
||||||
|
|| sub_extension.views.is_some()
|
||||||
{
|
{
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"invalid sub-extension [{}-{}]: fields [commands/scripts/quicklinks] should not be set in sub-extensions",
|
"invalid sub-extension [{}-{}]: fields [commands/scripts/quicklinks/views] should not be set in sub-extensions",
|
||||||
extension_id, sub_extension.id
|
extension_id, sub_extension.id
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -171,6 +180,13 @@ fn check_sub_extension_only(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if sub_extension.minimum_coco_version.is_some() {
|
||||||
|
return Err(format!(
|
||||||
|
"invalid sub-extension [{}-{}]: [{}] cannot be set for sub-extensions",
|
||||||
|
extension_id, sub_extension.id, PLUGIN_JSON_FIELD_MINIMUM_COCO_VERSION
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,6 +224,29 @@ 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
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// If field `ui` is Some, then it should be a View
|
||||||
|
if extension.ui.is_some() && extension.r#type != ExtensionType::View {
|
||||||
|
return Err(format!(
|
||||||
|
"invalid {}, field [ui] is set for a non-View extension",
|
||||||
|
identifier
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,6 +259,12 @@ mod tests {
|
|||||||
|
|
||||||
/// Helper function to create a basic valid extension
|
/// Helper function to create a basic valid extension
|
||||||
fn create_basic_extension(id: &str, extension_type: ExtensionType) -> Extension {
|
fn create_basic_extension(id: &str, extension_type: ExtensionType) -> Extension {
|
||||||
|
let page = if extension_type == ExtensionType::View {
|
||||||
|
Some("index.html".into())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
Extension {
|
Extension {
|
||||||
id: id.to_string(),
|
id: id.to_string(),
|
||||||
name: "Test Extension".to_string(),
|
name: "Test Extension".to_string(),
|
||||||
@@ -233,10 +278,15 @@ mod tests {
|
|||||||
commands: None,
|
commands: None,
|
||||||
scripts: None,
|
scripts: None,
|
||||||
quicklinks: None,
|
quicklinks: None,
|
||||||
|
views: None,
|
||||||
alias: None,
|
alias: None,
|
||||||
hotkey: None,
|
hotkey: None,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
page,
|
||||||
|
ui: None,
|
||||||
|
permission: None,
|
||||||
settings: None,
|
settings: None,
|
||||||
|
minimum_coco_version: None,
|
||||||
screenshots: None,
|
screenshots: None,
|
||||||
url: None,
|
url: None,
|
||||||
version: None,
|
version: None,
|
||||||
@@ -401,6 +451,36 @@ mod tests {
|
|||||||
.contains("field [quicklink] is set for a non-Quicklink extension")
|
.contains("field [quicklink] is set for a non-Quicklink extension")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_view_must_have_page_field() {
|
||||||
|
let mut extension = create_basic_extension("test-view", ExtensionType::View);
|
||||||
|
// create_basic_extension() will set its page field if type is View, clear it
|
||||||
|
extension.page = None;
|
||||||
|
|
||||||
|
let result = general_check(&extension);
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(
|
||||||
|
result
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("field [page] should be set for a View extension")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_non_view_cannot_have_page_field() {
|
||||||
|
let mut extension = create_basic_extension("test-cmd", ExtensionType::Command);
|
||||||
|
extension.action = Some(create_command_action());
|
||||||
|
extension.page = Some("index.html".into());
|
||||||
|
|
||||||
|
let result = general_check(&extension);
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(
|
||||||
|
result
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("field [page] is set for a non-View extension")
|
||||||
|
);
|
||||||
|
}
|
||||||
/* test check_main_extension_or_sub_extension */
|
/* test check_main_extension_or_sub_extension */
|
||||||
|
|
||||||
/* Test check_sub_extension_only */
|
/* Test check_sub_extension_only */
|
||||||
@@ -466,11 +546,24 @@ mod tests {
|
|||||||
|
|
||||||
let result = general_check(&extension);
|
let result = general_check(&extension);
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
assert!(
|
assert!(result.unwrap_err().contains(
|
||||||
result.unwrap_err().contains(
|
"fields [commands/scripts/quicklinks/views] should not be set in sub-extensions"
|
||||||
"fields [commands/scripts/quicklinks] should not be set in sub-extensions"
|
));
|
||||||
)
|
}
|
||||||
);
|
|
||||||
|
#[test]
|
||||||
|
fn test_sub_extension_cannot_set_minimum_coco_version() {
|
||||||
|
let mut extension = create_basic_extension("test-group", ExtensionType::Group);
|
||||||
|
let mut sub_cmd = create_basic_extension("sub-cmd", ExtensionType::Command);
|
||||||
|
sub_cmd.minimum_coco_version = Some(semver::Version::new(0, 8, 0));
|
||||||
|
extension.commands = Some(vec![sub_cmd]);
|
||||||
|
|
||||||
|
let result = general_check(&extension);
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(result.unwrap_err().contains(&format!(
|
||||||
|
"[{}] cannot be set for sub-extensions",
|
||||||
|
PLUGIN_JSON_FIELD_MINIMUM_COCO_VERSION
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
/* Test check_sub_extension_only */
|
/* Test check_sub_extension_only */
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use super::check_compatibility_via_mcv;
|
||||||
use crate::extension::PLUGIN_JSON_FILE_NAME;
|
use crate::extension::PLUGIN_JSON_FILE_NAME;
|
||||||
use crate::extension::third_party::check::general_check;
|
use crate::extension::third_party::check::general_check;
|
||||||
use crate::extension::third_party::install::{
|
use crate::extension::third_party::install::{
|
||||||
@@ -6,7 +7,9 @@ use crate::extension::third_party::install::{
|
|||||||
use crate::extension::third_party::{
|
use crate::extension::third_party::{
|
||||||
THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE, get_third_party_extension_directory,
|
THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE, get_third_party_extension_directory,
|
||||||
};
|
};
|
||||||
use crate::extension::{Extension, canonicalize_relative_icon_path};
|
use crate::extension::{
|
||||||
|
Extension, canonicalize_relative_icon_path, canonicalize_relative_page_path,
|
||||||
|
};
|
||||||
use crate::util::platform::Platform;
|
use crate::util::platform::Platform;
|
||||||
use serde_json::Value as Json;
|
use serde_json::Value as Json;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
@@ -77,6 +80,10 @@ pub(crate) async fn install_local_extension(
|
|||||||
let mut extension_json: Json =
|
let mut extension_json: Json =
|
||||||
serde_json::from_str(&plugin_json_content).map_err(|e| e.to_string())?;
|
serde_json::from_str(&plugin_json_content).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
if !check_compatibility_via_mcv(&extension_json)? {
|
||||||
|
return Err("app_incompatible".into());
|
||||||
|
}
|
||||||
|
|
||||||
// Set the main extension ID to the directory name
|
// Set the main extension ID to the directory name
|
||||||
let extension_obj = extension_json
|
let extension_obj = extension_json
|
||||||
.as_object_mut()
|
.as_object_mut()
|
||||||
@@ -156,7 +163,7 @@ pub(crate) async fn install_local_extension(
|
|||||||
//
|
//
|
||||||
// This is definitely error-prone, but we have to do this until we have
|
// This is definitely error-prone, but we have to do this until we have
|
||||||
// structured error type
|
// structured error type
|
||||||
return Err("incompatible".into());
|
return Err("platform_incompatible".into());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/* Check ends here */
|
/* Check ends here */
|
||||||
@@ -219,8 +226,9 @@ pub(crate) async fn install_local_extension(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
// Canonicalize relative icon paths
|
// Canonicalize relative icon and page paths
|
||||||
canonicalize_relative_icon_path(&dest_dir, &mut extension)?;
|
canonicalize_relative_icon_path(&dest_dir, &mut extension)?;
|
||||||
|
canonicalize_relative_page_path(&dest_dir, &mut extension)?;
|
||||||
|
|
||||||
// Add extension to the search source
|
// Add extension to the search source
|
||||||
third_party_ext_list_write_lock.push(extension);
|
third_party_ext_list_write_lock.push(extension);
|
||||||
|
|||||||
163
src-tauri/src/extension/third_party/install/mod.rs
vendored
163
src-tauri/src/extension/third_party/install/mod.rs
vendored
@@ -3,31 +3,53 @@
|
|||||||
//!
|
//!
|
||||||
//! # How
|
//! # How
|
||||||
//!
|
//!
|
||||||
//! Technically, installing an extension involves the following steps:
|
//! Technically, installing an extension involves the following steps. The order
|
||||||
//!
|
//! varies between 2 implementations.
|
||||||
//! 1. Correct the `plugin.json` JSON if it does not conform to our `struct Extension`
|
|
||||||
//! definition.
|
|
||||||
//!
|
//!
|
||||||
//! 2. Write the extension files to the corresponding location
|
//! 1. Check if it is already installed, if so, return
|
||||||
|
//!
|
||||||
|
//! 2. Check if it is compatible by inspecting the "minimum_coco_version"
|
||||||
|
//! field. If it is incompatible, reject and error out.
|
||||||
|
//!
|
||||||
|
//! This should be done before convert `plugin.json` JSON to `struct Extension`
|
||||||
|
//! as the definition of `struct Extension` could change in the future, in this
|
||||||
|
//! case, we want to tell users that "it is an incompatible extension" rather
|
||||||
|
//! than "this extension is invalid".
|
||||||
|
//!
|
||||||
|
//! 3. 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.
|
||||||
|
//!
|
||||||
|
//! 4. Validate the corrected `plugin.json`
|
||||||
|
//! 1. misc checks
|
||||||
|
//! 2. Platform compatibility check
|
||||||
|
//!
|
||||||
|
//! 5. Write the extension files to the corresponding location
|
||||||
//!
|
//!
|
||||||
//! * developer directory
|
//! * developer directory
|
||||||
//! * extension directory
|
//! * extension directory
|
||||||
//! * assets directory
|
//! * assets directory
|
||||||
//! * various assets files, e.g., "icon.png"
|
//! * various assets files, e.g., "icon.png"
|
||||||
//! * plugin.json file
|
//! * plugin.json file
|
||||||
|
//! * View pages if exist
|
||||||
//!
|
//!
|
||||||
//! 3. Canonicalize the `Extension.icon` fields if they are relative paths
|
//! 6. Canonicalize `Extension.icon` and `Extension.page` fields if they are
|
||||||
//! (relative to the `assets` directory)
|
//! relative paths
|
||||||
//!
|
//!
|
||||||
//! 4. Deserialize the `plugin.json` file to a `struct Extension`, and call
|
//! * icon: relative to the `assets` directory
|
||||||
//! `THIRD_PARTY_EXTENSIONS_DIRECTORY.add_extension(extension)` to add it to
|
//! * page: relative to the extension root directory
|
||||||
//! the in-memory extension list.
|
//!
|
||||||
|
//! 7. Add the extension to the in-memory extension list.
|
||||||
|
|
||||||
pub(crate) mod local_extension;
|
pub(crate) mod local_extension;
|
||||||
pub(crate) mod store;
|
pub(crate) mod store;
|
||||||
|
|
||||||
use crate::extension::Extension;
|
use crate::extension::Extension;
|
||||||
|
use crate::extension::PLUGIN_JSON_FIELD_MINIMUM_COCO_VERSION;
|
||||||
use crate::util::platform::Platform;
|
use crate::util::platform::Platform;
|
||||||
|
use crate::util::version::{COCO_VERSION, parse_coco_semver};
|
||||||
|
use serde_json::Value as Json;
|
||||||
|
use std::ops::Deref;
|
||||||
|
|
||||||
use super::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE;
|
use super::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE;
|
||||||
|
|
||||||
@@ -51,14 +73,16 @@ pub(crate) fn filter_out_incompatible_sub_extensions(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For main extensions, None means all.
|
||||||
|
let main_extension_supported_platforms = extension.platforms.clone().unwrap_or(Platform::all());
|
||||||
|
|
||||||
// Filter commands
|
// Filter commands
|
||||||
if let Some(ref mut commands) = extension.commands {
|
if let Some(ref mut commands) = extension.commands {
|
||||||
commands.retain(|sub_ext| {
|
commands.retain(|sub_ext| {
|
||||||
// If platforms is None, the sub-extension is compatible with all platforms
|
|
||||||
if let Some(ref platforms) = sub_ext.platforms {
|
if let Some(ref platforms) = sub_ext.platforms {
|
||||||
platforms.contains(¤t_platform)
|
platforms.contains(¤t_platform)
|
||||||
} else {
|
} else {
|
||||||
true
|
main_extension_supported_platforms.contains(¤t_platform)
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -66,11 +90,10 @@ pub(crate) fn filter_out_incompatible_sub_extensions(
|
|||||||
// Filter scripts
|
// Filter scripts
|
||||||
if let Some(ref mut scripts) = extension.scripts {
|
if let Some(ref mut scripts) = extension.scripts {
|
||||||
scripts.retain(|sub_ext| {
|
scripts.retain(|sub_ext| {
|
||||||
// If platforms is None, the sub-extension is compatible with all platforms
|
|
||||||
if let Some(ref platforms) = sub_ext.platforms {
|
if let Some(ref platforms) = sub_ext.platforms {
|
||||||
platforms.contains(¤t_platform)
|
platforms.contains(¤t_platform)
|
||||||
} else {
|
} else {
|
||||||
true
|
main_extension_supported_platforms.contains(¤t_platform)
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -78,14 +101,51 @@ pub(crate) fn filter_out_incompatible_sub_extensions(
|
|||||||
// Filter quicklinks
|
// Filter quicklinks
|
||||||
if let Some(ref mut quicklinks) = extension.quicklinks {
|
if let Some(ref mut quicklinks) = extension.quicklinks {
|
||||||
quicklinks.retain(|sub_ext| {
|
quicklinks.retain(|sub_ext| {
|
||||||
// If platforms is None, the sub-extension is compatible with all platforms
|
|
||||||
if let Some(ref platforms) = sub_ext.platforms {
|
if let Some(ref platforms) = sub_ext.platforms {
|
||||||
platforms.contains(¤t_platform)
|
platforms.contains(¤t_platform)
|
||||||
} else {
|
} else {
|
||||||
true
|
main_extension_supported_platforms.contains(¤t_platform)
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Filter views
|
||||||
|
if let Some(ref mut views) = extension.views {
|
||||||
|
views.retain(|sub_ext| {
|
||||||
|
if let Some(ref platforms) = sub_ext.platforms {
|
||||||
|
platforms.contains(¤t_platform)
|
||||||
|
} else {
|
||||||
|
main_extension_supported_platforms.contains(¤t_platform)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inspect the "minimum_coco_version" field and see if this extension is
|
||||||
|
/// compatible with the current Coco app.
|
||||||
|
fn check_compatibility_via_mcv(plugin_json: &Json) -> Result<bool, String> {
|
||||||
|
let Some(mcv_json) = plugin_json.get(PLUGIN_JSON_FIELD_MINIMUM_COCO_VERSION) else {
|
||||||
|
return Ok(true);
|
||||||
|
};
|
||||||
|
if mcv_json == &Json::Null {
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(mcv_str) = mcv_json.as_str() else {
|
||||||
|
return Err(format!(
|
||||||
|
"invalid extension: field [{}] should be a string",
|
||||||
|
PLUGIN_JSON_FIELD_MINIMUM_COCO_VERSION
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(mcv) = parse_coco_semver(mcv_str) else {
|
||||||
|
return Err(format!(
|
||||||
|
"invalid extension: [{}] is not a valid version string",
|
||||||
|
PLUGIN_JSON_FIELD_MINIMUM_COCO_VERSION
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(COCO_VERSION.deref() >= &mcv)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -113,10 +173,15 @@ mod tests {
|
|||||||
commands: None,
|
commands: None,
|
||||||
scripts: None,
|
scripts: None,
|
||||||
quicklinks: None,
|
quicklinks: None,
|
||||||
|
views: None,
|
||||||
alias: None,
|
alias: None,
|
||||||
hotkey: None,
|
hotkey: None,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
settings: None,
|
settings: None,
|
||||||
|
page: None,
|
||||||
|
ui: None,
|
||||||
|
minimum_coco_version: None,
|
||||||
|
permission: None,
|
||||||
screenshots: None,
|
screenshots: None,
|
||||||
url: None,
|
url: None,
|
||||||
version: None,
|
version: None,
|
||||||
@@ -154,10 +219,15 @@ mod tests {
|
|||||||
ExtensionType::Script,
|
ExtensionType::Script,
|
||||||
Some(HashSet::from([Platform::Macos])),
|
Some(HashSet::from([Platform::Macos])),
|
||||||
)];
|
)];
|
||||||
|
let views = vec![create_test_extension(
|
||||||
|
ExtensionType::View,
|
||||||
|
Some(HashSet::from([Platform::Macos])),
|
||||||
|
)];
|
||||||
// Set sub extensions
|
// Set sub extensions
|
||||||
main_extension.commands = Some(commands);
|
main_extension.commands = Some(commands);
|
||||||
main_extension.quicklinks = Some(quicklinks);
|
main_extension.quicklinks = Some(quicklinks);
|
||||||
main_extension.scripts = Some(scripts);
|
main_extension.scripts = Some(scripts);
|
||||||
|
main_extension.views = Some(views);
|
||||||
|
|
||||||
// Current platform is Linux, all the sub extensions should be filtered out.
|
// Current platform is Linux, all the sub extensions should be filtered out.
|
||||||
filter_out_incompatible_sub_extensions(&mut main_extension, Platform::Linux);
|
filter_out_incompatible_sub_extensions(&mut main_extension, Platform::Linux);
|
||||||
@@ -166,6 +236,7 @@ mod tests {
|
|||||||
assert!(main_extension.commands.unwrap().is_empty());
|
assert!(main_extension.commands.unwrap().is_empty());
|
||||||
assert!(main_extension.quicklinks.unwrap().is_empty());
|
assert!(main_extension.quicklinks.unwrap().is_empty());
|
||||||
assert!(main_extension.scripts.unwrap().is_empty());
|
assert!(main_extension.scripts.unwrap().is_empty());
|
||||||
|
assert!(main_extension.views.unwrap().is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sub extensions are compatible with all the platforms, nothing to filter out.
|
/// Sub extensions are compatible with all the platforms, nothing to filter out.
|
||||||
@@ -186,10 +257,15 @@ mod tests {
|
|||||||
ExtensionType::Script,
|
ExtensionType::Script,
|
||||||
Some(Platform::all()),
|
Some(Platform::all()),
|
||||||
)];
|
)];
|
||||||
|
let views = vec![create_test_extension(
|
||||||
|
ExtensionType::View,
|
||||||
|
Some(Platform::all()),
|
||||||
|
)];
|
||||||
// Set sub extensions
|
// Set sub extensions
|
||||||
main_extension.commands = Some(commands);
|
main_extension.commands = Some(commands);
|
||||||
main_extension.quicklinks = Some(quicklinks);
|
main_extension.quicklinks = Some(quicklinks);
|
||||||
main_extension.scripts = Some(scripts);
|
main_extension.scripts = Some(scripts);
|
||||||
|
main_extension.views = Some(views);
|
||||||
|
|
||||||
// Current platform is Linux, all the sub extensions should be filtered out.
|
// Current platform is Linux, all the sub extensions should be filtered out.
|
||||||
filter_out_incompatible_sub_extensions(&mut main_extension, Platform::Linux);
|
filter_out_incompatible_sub_extensions(&mut main_extension, Platform::Linux);
|
||||||
@@ -198,19 +274,23 @@ mod tests {
|
|||||||
assert_eq!(main_extension.commands.unwrap().len(), 1);
|
assert_eq!(main_extension.commands.unwrap().len(), 1);
|
||||||
assert_eq!(main_extension.quicklinks.unwrap().len(), 1);
|
assert_eq!(main_extension.quicklinks.unwrap().len(), 1);
|
||||||
assert_eq!(main_extension.scripts.unwrap().len(), 1);
|
assert_eq!(main_extension.scripts.unwrap().len(), 1);
|
||||||
|
assert_eq!(main_extension.views.unwrap().len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// `platforms: None` means all platforms as well
|
// main extension is compatible with all platforms, sub extension's platforms
|
||||||
|
// is None, which means all platforms are supported
|
||||||
{
|
{
|
||||||
let mut main_extension = create_test_extension(ExtensionType::Group, None);
|
let mut main_extension = create_test_extension(ExtensionType::Group, None);
|
||||||
// init sub extensions, which are compatible with all the platforms
|
// init sub extensions, which are compatible with all the platforms
|
||||||
let commands = vec![create_test_extension(ExtensionType::Command, None)];
|
let commands = vec![create_test_extension(ExtensionType::Command, None)];
|
||||||
let quicklinks = vec![create_test_extension(ExtensionType::Quicklink, None)];
|
let quicklinks = vec![create_test_extension(ExtensionType::Quicklink, None)];
|
||||||
let scripts = vec![create_test_extension(ExtensionType::Script, None)];
|
let scripts = vec![create_test_extension(ExtensionType::Script, None)];
|
||||||
|
let views = vec![create_test_extension(ExtensionType::View, None)];
|
||||||
// Set sub extensions
|
// Set sub extensions
|
||||||
main_extension.commands = Some(commands);
|
main_extension.commands = Some(commands);
|
||||||
main_extension.quicklinks = Some(quicklinks);
|
main_extension.quicklinks = Some(quicklinks);
|
||||||
main_extension.scripts = Some(scripts);
|
main_extension.scripts = Some(scripts);
|
||||||
|
main_extension.views = Some(views);
|
||||||
|
|
||||||
// Current platform is Linux, all the sub extensions should be filtered out.
|
// Current platform is Linux, all the sub extensions should be filtered out.
|
||||||
filter_out_incompatible_sub_extensions(&mut main_extension, Platform::Linux);
|
filter_out_incompatible_sub_extensions(&mut main_extension, Platform::Linux);
|
||||||
@@ -219,6 +299,55 @@ mod tests {
|
|||||||
assert_eq!(main_extension.commands.unwrap().len(), 1);
|
assert_eq!(main_extension.commands.unwrap().len(), 1);
|
||||||
assert_eq!(main_extension.quicklinks.unwrap().len(), 1);
|
assert_eq!(main_extension.quicklinks.unwrap().len(), 1);
|
||||||
assert_eq!(main_extension.scripts.unwrap().len(), 1);
|
assert_eq!(main_extension.scripts.unwrap().len(), 1);
|
||||||
|
assert_eq!(main_extension.views.unwrap().len(), 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_main_extension_is_incompatible_sub_extension_platforms_none() {
|
||||||
|
{
|
||||||
|
let mut main_extension =
|
||||||
|
create_test_extension(ExtensionType::Group, Some(HashSet::from([Platform::Macos])));
|
||||||
|
let commands = vec![create_test_extension(ExtensionType::Command, None)];
|
||||||
|
main_extension.commands = Some(commands);
|
||||||
|
filter_out_incompatible_sub_extensions(&mut main_extension, Platform::Linux);
|
||||||
|
assert_eq!(main_extension.commands.unwrap().len(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut main_extension =
|
||||||
|
create_test_extension(ExtensionType::Group, Some(HashSet::from([Platform::Macos])));
|
||||||
|
let scripts = vec![create_test_extension(ExtensionType::Script, None)];
|
||||||
|
main_extension.scripts = Some(scripts);
|
||||||
|
filter_out_incompatible_sub_extensions(&mut main_extension, Platform::Linux);
|
||||||
|
assert_eq!(main_extension.scripts.unwrap().len(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut main_extension =
|
||||||
|
create_test_extension(ExtensionType::Group, Some(HashSet::from([Platform::Macos])));
|
||||||
|
let quicklinks = vec![create_test_extension(ExtensionType::Quicklink, None)];
|
||||||
|
main_extension.quicklinks = Some(quicklinks);
|
||||||
|
filter_out_incompatible_sub_extensions(&mut main_extension, Platform::Linux);
|
||||||
|
assert_eq!(main_extension.quicklinks.unwrap().len(), 0);
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let mut main_extension =
|
||||||
|
create_test_extension(ExtensionType::Group, Some(HashSet::from([Platform::Macos])));
|
||||||
|
let views = vec![create_test_extension(ExtensionType::View, None)];
|
||||||
|
main_extension.views = Some(views);
|
||||||
|
filter_out_incompatible_sub_extensions(&mut main_extension, Platform::Linux);
|
||||||
|
assert_eq!(main_extension.views.unwrap().len(), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_main_extension_compatible_sub_extension_platforms_none() {
|
||||||
|
let mut main_extension =
|
||||||
|
create_test_extension(ExtensionType::Group, Some(HashSet::from([Platform::Macos])));
|
||||||
|
let views = vec![create_test_extension(ExtensionType::View, None)];
|
||||||
|
main_extension.views = Some(views);
|
||||||
|
filter_out_incompatible_sub_extensions(&mut main_extension, Platform::Macos);
|
||||||
|
assert_eq!(main_extension.views.unwrap().len(), 1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
//! Extension store related stuff.
|
//! Extension store related stuff.
|
||||||
|
|
||||||
use super::super::LOCAL_QUERY_SOURCE_TYPE;
|
use super::super::LOCAL_QUERY_SOURCE_TYPE;
|
||||||
|
use super::check_compatibility_via_mcv;
|
||||||
use super::is_extension_installed;
|
use super::is_extension_installed;
|
||||||
use crate::common::document::DataSourceReference;
|
use crate::common::document::DataSourceReference;
|
||||||
use crate::common::document::Document;
|
use crate::common::document::Document;
|
||||||
@@ -13,13 +14,13 @@ use crate::extension::Extension;
|
|||||||
use crate::extension::PLUGIN_JSON_FILE_NAME;
|
use crate::extension::PLUGIN_JSON_FILE_NAME;
|
||||||
use crate::extension::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE;
|
use crate::extension::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE;
|
||||||
use crate::extension::canonicalize_relative_icon_path;
|
use crate::extension::canonicalize_relative_icon_path;
|
||||||
|
use crate::extension::canonicalize_relative_page_path;
|
||||||
use crate::extension::third_party::check::general_check;
|
use crate::extension::third_party::check::general_check;
|
||||||
use crate::extension::third_party::get_third_party_extension_directory;
|
use crate::extension::third_party::get_third_party_extension_directory;
|
||||||
use crate::extension::third_party::install::filter_out_incompatible_sub_extensions;
|
use crate::extension::third_party::install::filter_out_incompatible_sub_extensions;
|
||||||
use crate::server::http_client::HttpClient;
|
use crate::server::http_client::HttpClient;
|
||||||
use crate::util::platform::Platform;
|
use crate::util::platform::Platform;
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use http::Method;
|
|
||||||
use reqwest::StatusCode;
|
use reqwest::StatusCode;
|
||||||
use serde_json::Map as JsonObject;
|
use serde_json::Map as JsonObject;
|
||||||
use serde_json::Value as Json;
|
use serde_json::Value as Json;
|
||||||
@@ -103,15 +104,23 @@ pub(crate) async fn search_extension(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Failed to send request: {:?}", e))?;
|
.map_err(|e| format!("Failed to send request: {:?}", e))?;
|
||||||
|
|
||||||
|
if response.status() == StatusCode::NOT_FOUND {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
// The response of a ES style search request
|
// The response of a ES style search request
|
||||||
let mut response: JsonObject<String, Json> = response
|
let mut response: JsonObject<String, Json> = response
|
||||||
.json()
|
.json()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Failed to parse response: {:?}", e))?;
|
.map_err(|e| format!("Failed to parse response: {:?}", e))?;
|
||||||
|
|
||||||
let hits_json = response
|
let hits_json = response.remove("hits").unwrap_or_else(|| {
|
||||||
.remove("hits")
|
panic!(
|
||||||
.expect("the JSON response should contain field [hits]");
|
"the JSON response should contain field [hits], response [{:?}]",
|
||||||
|
response
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
let mut hits = match hits_json {
|
let mut hits = match hits_json {
|
||||||
Json::Object(obj) => obj,
|
Json::Object(obj) => obj,
|
||||||
_ => panic!(
|
_ => panic!(
|
||||||
@@ -177,9 +186,10 @@ pub(crate) async fn search_extension(
|
|||||||
pub(crate) async fn extension_detail(
|
pub(crate) async fn extension_detail(
|
||||||
id: String,
|
id: String,
|
||||||
) -> Result<Option<JsonObject<String, Json>>, String> {
|
) -> Result<Option<JsonObject<String, Json>>, String> {
|
||||||
let url = format!("http://dev.infini.cloud:27200/store/extension/{}", id);
|
let path = format!("store/extension/{}", id);
|
||||||
let response =
|
let response = HttpClient::get("default_coco_server", path.as_str(), None)
|
||||||
HttpClient::send_raw_request(Method::GET, url.as_str(), None, None, None).await?;
|
.await
|
||||||
|
.map_err(|e| format!("Failed to send request: {:?}", e))?;
|
||||||
|
|
||||||
if response.status() == StatusCode::NOT_FOUND {
|
if response.status() == StatusCode::NOT_FOUND {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
@@ -257,6 +267,10 @@ pub(crate) async fn install_extension_from_store(
|
|||||||
let mut extension: Json = serde_json::from_str(&plugin_json_content)
|
let mut extension: Json = serde_json::from_str(&plugin_json_content)
|
||||||
.map_err(|e| format!("Failed to parse plugin.json: {}", e))?;
|
.map_err(|e| format!("Failed to parse plugin.json: {}", e))?;
|
||||||
|
|
||||||
|
if !check_compatibility_via_mcv(&extension)? {
|
||||||
|
return Err("app_incompatible".into());
|
||||||
|
}
|
||||||
|
|
||||||
let mut_ref_to_developer_object: &mut Json = extension
|
let mut_ref_to_developer_object: &mut Json = extension
|
||||||
.as_object_mut()
|
.as_object_mut()
|
||||||
.expect("plugin.json should be an object")
|
.expect("plugin.json should be an object")
|
||||||
@@ -306,7 +320,7 @@ pub(crate) async fn install_extension_from_store(
|
|||||||
let current_platform = Platform::current();
|
let current_platform = Platform::current();
|
||||||
if let Some(ref platforms) = extension.platforms {
|
if let Some(ref platforms) = extension.platforms {
|
||||||
if !platforms.contains(¤t_platform) {
|
if !platforms.contains(¤t_platform) {
|
||||||
return Err("this extension is not compatible with your OS".into());
|
return Err("platform_incompatible".into());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -394,8 +408,9 @@ pub(crate) async fn install_extension_from_store(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
// Turn it into an absolute path if it is a valid relative path because frontend code need this.
|
// Canonicalize relative icon and page paths
|
||||||
canonicalize_relative_icon_path(&extension_directory, &mut extension)?;
|
canonicalize_relative_icon_path(&extension_directory, &mut extension)?;
|
||||||
|
canonicalize_relative_page_path(&extension_directory, &mut extension)?;
|
||||||
|
|
||||||
third_party_ext_list_write_lock.push(extension);
|
third_party_ext_list_write_lock.push(extension);
|
||||||
|
|
||||||
|
|||||||
411
src-tauri/src/extension/third_party/mod.rs
vendored
411
src-tauri/src/extension/third_party/mod.rs
vendored
@@ -15,12 +15,22 @@ use crate::common::search::QuerySource;
|
|||||||
use crate::common::search::SearchQuery;
|
use crate::common::search::SearchQuery;
|
||||||
use crate::common::traits::SearchSource;
|
use crate::common::traits::SearchSource;
|
||||||
use crate::extension::ExtensionBundleIdBorrowed;
|
use crate::extension::ExtensionBundleIdBorrowed;
|
||||||
|
use crate::extension::ExtensionType;
|
||||||
|
use crate::extension::PLUGIN_JSON_FIELD_MINIMUM_COCO_VERSION;
|
||||||
|
use crate::extension::calculate_text_similarity;
|
||||||
|
use crate::extension::canonicalize_relative_page_path;
|
||||||
|
use crate::extension::is_extension_compatible;
|
||||||
use crate::util::platform::Platform;
|
use crate::util::platform::Platform;
|
||||||
|
use crate::util::version::COCO_VERSION;
|
||||||
|
use crate::util::version::parse_coco_semver;
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use borrowme::ToOwned;
|
use borrowme::ToOwned;
|
||||||
use check::general_check;
|
use check::general_check;
|
||||||
use function_name::named;
|
use function_name::named;
|
||||||
|
use semver::Version as SemVer;
|
||||||
|
use serde_json::Value as Json;
|
||||||
use std::io::ErrorKind;
|
use std::io::ErrorKind;
|
||||||
|
use std::ops::Deref;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -120,6 +130,154 @@ pub(crate) async fn load_third_party_extensions_from_directory(
|
|||||||
let plugin_json_file_content = tokio::fs::read_to_string(&plugin_json_file_path)
|
let plugin_json_file_content = tokio::fs::read_to_string(&plugin_json_file_path)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let plugin_json = match serde_json::from_str::<Json>(&plugin_json_file_content) {
|
||||||
|
Ok(json) => json,
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!(
|
||||||
|
"invalid extension: [{}]: file [{}] is not a JSON, error: '{}'",
|
||||||
|
extension_dir_file_name,
|
||||||
|
plugin_json_file_path.display(),
|
||||||
|
e
|
||||||
|
);
|
||||||
|
continue 'extension;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let opt_mcv: Option<SemVer> = {
|
||||||
|
match plugin_json.get(PLUGIN_JSON_FIELD_MINIMUM_COCO_VERSION) {
|
||||||
|
None => None,
|
||||||
|
// NULL is considered None as well.
|
||||||
|
Some(Json::Null) => None,
|
||||||
|
|
||||||
|
Some(mcv_json) => {
|
||||||
|
let Some(mcv_str) = mcv_json.as_str() else {
|
||||||
|
log::warn!(
|
||||||
|
"invalid extension: [{}]: field [{}] is not a string",
|
||||||
|
extension_dir_file_name,
|
||||||
|
PLUGIN_JSON_FIELD_MINIMUM_COCO_VERSION
|
||||||
|
);
|
||||||
|
continue 'extension;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(mcv) = parse_coco_semver(mcv_str) else {
|
||||||
|
log::warn!(
|
||||||
|
"invalid extension: [{}]: field [{}] has invalid version string",
|
||||||
|
extension_dir_file_name,
|
||||||
|
PLUGIN_JSON_FIELD_MINIMUM_COCO_VERSION
|
||||||
|
);
|
||||||
|
continue 'extension;
|
||||||
|
};
|
||||||
|
|
||||||
|
Some(mcv)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let is_compatible: bool = match opt_mcv {
|
||||||
|
Some(ref mcv) => COCO_VERSION.deref() >= mcv,
|
||||||
|
None => true,
|
||||||
|
};
|
||||||
|
|
||||||
|
if !is_compatible {
|
||||||
|
/*
|
||||||
|
* Extract only these field: [id, name, icon, type] from the JSON,
|
||||||
|
* then return a minimal Extension instance with these fields set:
|
||||||
|
*
|
||||||
|
* - `id` and `developer`: to make it identifiable
|
||||||
|
* - `name`, `icon` and `type`: to display it in the Extensions page
|
||||||
|
* - `minimum_coco_version`: so that we can check compatibility using it
|
||||||
|
*/
|
||||||
|
let Some(id) = plugin_json.get("id").and_then(|v| v.as_str()) else {
|
||||||
|
log::warn!(
|
||||||
|
"invalid extension: [{}]: field [id] is missing or not a string",
|
||||||
|
extension_dir_file_name,
|
||||||
|
);
|
||||||
|
continue 'extension;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(name) = plugin_json.get("name").and_then(|v| v.as_str()) else {
|
||||||
|
log::warn!(
|
||||||
|
"invalid extension: [{}]: field [name] is missing or not a string",
|
||||||
|
extension_dir_file_name,
|
||||||
|
);
|
||||||
|
continue 'extension;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(icon) = plugin_json.get("icon").and_then(|v| v.as_str()) else {
|
||||||
|
log::warn!(
|
||||||
|
"invalid extension: [{}]: field [icon] is missing or not a string",
|
||||||
|
extension_dir_file_name,
|
||||||
|
);
|
||||||
|
continue 'extension;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(extension_type_str) = plugin_json.get("type").and_then(|v| v.as_str())
|
||||||
|
else {
|
||||||
|
log::warn!(
|
||||||
|
"invalid extension: [{}]: field [type] is missing or not a string",
|
||||||
|
extension_dir_file_name,
|
||||||
|
);
|
||||||
|
continue 'extension;
|
||||||
|
};
|
||||||
|
|
||||||
|
let extension_type: ExtensionType = match serde_plain::from_str(extension_type_str)
|
||||||
|
{
|
||||||
|
Ok(t) => t,
|
||||||
|
// Future Coco may have new Extension types that the we don't know
|
||||||
|
//
|
||||||
|
// This should be the only place where `ExtensionType::Unknown`
|
||||||
|
// could be constructed.
|
||||||
|
Err(_e) => ExtensionType::Unknown,
|
||||||
|
};
|
||||||
|
|
||||||
|
// We don't extract the developer ID from the plugin.json to rely
|
||||||
|
// less on it.
|
||||||
|
let developer = developer_dir
|
||||||
|
.file_name()
|
||||||
|
.into_string()
|
||||||
|
.expect("developer ID should be UTF-8 encoded");
|
||||||
|
|
||||||
|
let mut incompatible_extension = Extension {
|
||||||
|
id: id.to_string(),
|
||||||
|
name: name.to_string(),
|
||||||
|
icon: icon.to_string(),
|
||||||
|
r#type: extension_type,
|
||||||
|
developer: Some(developer),
|
||||||
|
description: String::new(),
|
||||||
|
enabled: false,
|
||||||
|
platforms: None,
|
||||||
|
action: None,
|
||||||
|
quicklink: None,
|
||||||
|
commands: None,
|
||||||
|
scripts: None,
|
||||||
|
quicklinks: None,
|
||||||
|
views: None,
|
||||||
|
alias: None,
|
||||||
|
hotkey: None,
|
||||||
|
settings: None,
|
||||||
|
page: None,
|
||||||
|
ui: None,
|
||||||
|
permission: None,
|
||||||
|
minimum_coco_version: opt_mcv,
|
||||||
|
screenshots: None,
|
||||||
|
url: None,
|
||||||
|
version: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Turn icon path into an absolute path if it is a valid relative path
|
||||||
|
canonicalize_relative_icon_path(
|
||||||
|
&extension_dir.path(),
|
||||||
|
&mut incompatible_extension,
|
||||||
|
)?;
|
||||||
|
// No need to canonicalize the path field as it is not set
|
||||||
|
|
||||||
|
extensions.push(incompatible_extension);
|
||||||
|
continue 'extension;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This is a compatible extension.
|
||||||
|
*/
|
||||||
let mut extension = match serde_json::from_str::<Extension>(&plugin_json_file_content) {
|
let mut extension = match serde_json::from_str::<Extension>(&plugin_json_file_content) {
|
||||||
Ok(extension) => extension,
|
Ok(extension) => extension,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -182,6 +340,7 @@ pub(crate) async fn load_third_party_extensions_from_directory(
|
|||||||
|
|
||||||
// Turn it into an absolute path if it is a valid relative path because frontend code needs this.
|
// Turn it into an absolute path if it is a valid relative path because frontend code needs this.
|
||||||
canonicalize_relative_icon_path(&extension_dir.path(), &mut extension)?;
|
canonicalize_relative_icon_path(&extension_dir.path(), &mut extension)?;
|
||||||
|
canonicalize_relative_page_path(&extension_dir.path(), &mut extension)?;
|
||||||
|
|
||||||
extensions.push(extension);
|
extensions.push(extension);
|
||||||
}
|
}
|
||||||
@@ -243,7 +402,6 @@ impl ThirdPartyExtensionsSearchSource {
|
|||||||
if extension.supports_alias_hotkey() {
|
if extension.supports_alias_hotkey() {
|
||||||
if let Some(ref hotkey) = extension.hotkey {
|
if let Some(ref hotkey) = extension.hotkey {
|
||||||
let on_opened = extension.on_opened().unwrap_or_else(|| panic!( "extension has hotkey, but on_open() returns None, extension ID [{}], extension type [{:?}]", extension.id, extension.r#type));
|
let on_opened = extension.on_opened().unwrap_or_else(|| panic!( "extension has hotkey, but on_open() returns None, extension ID [{}], extension type [{:?}]", extension.id, extension.r#type));
|
||||||
|
|
||||||
let extension_id_clone = extension.id.clone();
|
let extension_id_clone = extension.id.clone();
|
||||||
|
|
||||||
tauri_app_handle
|
tauri_app_handle
|
||||||
@@ -289,6 +447,11 @@ impl ThirdPartyExtensionsSearchSource {
|
|||||||
Self::_enable_extension(&tauri_app_handle, quicklink).await?;
|
Self::_enable_extension(&tauri_app_handle, quicklink).await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if let Some(views) = &extension.views {
|
||||||
|
for view in views.iter().filter(|ext| ext.enabled) {
|
||||||
|
Self::_enable_extension(&tauri_app_handle, view).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -331,6 +494,11 @@ impl ThirdPartyExtensionsSearchSource {
|
|||||||
Self::_disable_extension(tauri_app_handle, quicklink).await?;
|
Self::_disable_extension(tauri_app_handle, quicklink).await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if let Some(views) = &extension.views {
|
||||||
|
for view in views.iter().filter(|ext| ext.enabled) {
|
||||||
|
Self::_disable_extension(tauri_app_handle, view).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -742,13 +910,28 @@ impl SearchSource for ThirdPartyExtensionsSearchSource {
|
|||||||
let extensions_read_lock =
|
let extensions_read_lock =
|
||||||
futures::executor::block_on(async { inner_clone.extensions.read().await });
|
futures::executor::block_on(async { inner_clone.extensions.read().await });
|
||||||
|
|
||||||
for extension in extensions_read_lock.iter().filter(|ext| ext.enabled) {
|
for extension in extensions_read_lock
|
||||||
|
.iter()
|
||||||
|
// field minimum_coco_extension is only set for main extensions.
|
||||||
|
.filter(|ext| ext.enabled && is_extension_compatible(Extension::clone(ext)))
|
||||||
|
{
|
||||||
if extension.r#type.contains_sub_items() {
|
if extension.r#type.contains_sub_items() {
|
||||||
|
let opt_main_extension_lowercase_name =
|
||||||
|
if extension.r#type == ExtensionType::Extension {
|
||||||
|
Some(extension.name.to_lowercase())
|
||||||
|
} else {
|
||||||
|
// None if it is of type `ExtensionType::Group`
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
if let Some(ref commands) = extension.commands {
|
if let Some(ref commands) = extension.commands {
|
||||||
for command in commands.iter().filter(|cmd| cmd.enabled) {
|
for command in commands.iter().filter(|cmd| cmd.enabled) {
|
||||||
if let Some(hit) =
|
if let Some(hit) = extension_to_hit(
|
||||||
extension_to_hit(command, &query_lower, opt_data_source.as_deref())
|
command,
|
||||||
{
|
&query_lower,
|
||||||
|
opt_data_source.as_deref(),
|
||||||
|
opt_main_extension_lowercase_name.as_deref(),
|
||||||
|
) {
|
||||||
hits.push(hit);
|
hits.push(hit);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -756,9 +939,12 @@ impl SearchSource for ThirdPartyExtensionsSearchSource {
|
|||||||
|
|
||||||
if let Some(ref scripts) = extension.scripts {
|
if let Some(ref scripts) = extension.scripts {
|
||||||
for script in scripts.iter().filter(|script| script.enabled) {
|
for script in scripts.iter().filter(|script| script.enabled) {
|
||||||
if let Some(hit) =
|
if let Some(hit) = extension_to_hit(
|
||||||
extension_to_hit(script, &query_lower, opt_data_source.as_deref())
|
script,
|
||||||
{
|
&query_lower,
|
||||||
|
opt_data_source.as_deref(),
|
||||||
|
opt_main_extension_lowercase_name.as_deref(),
|
||||||
|
) {
|
||||||
hits.push(hit);
|
hits.push(hit);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -770,6 +956,20 @@ impl SearchSource for ThirdPartyExtensionsSearchSource {
|
|||||||
quicklink,
|
quicklink,
|
||||||
&query_lower,
|
&query_lower,
|
||||||
opt_data_source.as_deref(),
|
opt_data_source.as_deref(),
|
||||||
|
opt_main_extension_lowercase_name.as_deref(),
|
||||||
|
) {
|
||||||
|
hits.push(hit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ref views) = extension.views {
|
||||||
|
for view in views.iter().filter(|view| view.enabled) {
|
||||||
|
if let Some(hit) = extension_to_hit(
|
||||||
|
view,
|
||||||
|
&query_lower,
|
||||||
|
opt_data_source.as_deref(),
|
||||||
|
opt_main_extension_lowercase_name.as_deref(),
|
||||||
) {
|
) {
|
||||||
hits.push(hit);
|
hits.push(hit);
|
||||||
}
|
}
|
||||||
@@ -777,7 +977,7 @@ impl SearchSource for ThirdPartyExtensionsSearchSource {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if let Some(hit) =
|
if let Some(hit) =
|
||||||
extension_to_hit(extension, &query_lower, opt_data_source.as_deref())
|
extension_to_hit(extension, &query_lower, opt_data_source.as_deref(), None)
|
||||||
{
|
{
|
||||||
hits.push(hit);
|
hits.push(hit);
|
||||||
}
|
}
|
||||||
@@ -803,10 +1003,31 @@ 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
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Argument `opt_main_extension_lowercase_name`: If `extension` is a sub-extension
|
||||||
|
/// of an `extension` type extension, then this argument contains the lowercase
|
||||||
|
/// name of that extension. Otherwise, None.
|
||||||
|
///
|
||||||
|
/// This argument is needed as an "extension" type extension should return all its
|
||||||
|
/// sub-extensions when the query string matches its name. To do this, we pass the
|
||||||
|
/// extension name, score it and take that into account.
|
||||||
|
pub(crate) fn extension_to_hit(
|
||||||
extension: &Extension,
|
extension: &Extension,
|
||||||
query_lower: &str,
|
query_lower: &str,
|
||||||
opt_data_source: Option<&str>,
|
opt_data_source: Option<&str>,
|
||||||
|
opt_main_extension_lowercase_name: Option<&str>,
|
||||||
) -> Option<(Document, f64)> {
|
) -> Option<(Document, f64)> {
|
||||||
if !extension.searchable() {
|
if !extension.searchable() {
|
||||||
return None;
|
return None;
|
||||||
@@ -829,14 +1050,26 @@ fn extension_to_hit(
|
|||||||
if let Some(title_score) =
|
if let Some(title_score) =
|
||||||
calculate_text_similarity(&query_lower, &extension.name.to_lowercase())
|
calculate_text_similarity(&query_lower, &extension.name.to_lowercase())
|
||||||
{
|
{
|
||||||
total_score += title_score * 1.0; // Weight for title
|
total_score += title_score;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Score based on alias match if available
|
// Score based on alias match if available
|
||||||
// Alias is considered less important than title, so it gets a lower weight.
|
// Alias is considered less important than title, so it gets a lower weight.
|
||||||
if let Some(alias) = &extension.alias {
|
if let Some(alias) = &extension.alias {
|
||||||
if let Some(alias_score) = calculate_text_similarity(&query_lower, &alias.to_lowercase()) {
|
if let Some(alias_score) = calculate_text_similarity(&query_lower, &alias.to_lowercase()) {
|
||||||
total_score += alias_score * 0.7; // Weight for alias
|
total_score += alias_score;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An "extension" type extension should return all its
|
||||||
|
// sub-extensions when the query string matches its ID.
|
||||||
|
// To do this, we score the extension ID and take that
|
||||||
|
// into account.
|
||||||
|
if let Some(main_extension_lowercase_id) = opt_main_extension_lowercase_name {
|
||||||
|
if let Some(main_extension_score) =
|
||||||
|
calculate_text_similarity(&query_lower, main_extension_lowercase_id)
|
||||||
|
{
|
||||||
|
total_score += main_extension_score;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -872,157 +1105,3 @@ fn extension_to_hit(
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculates a similarity score between a query and a text, aiming for a [0, 1] range.
|
|
||||||
// Assumes query and text are already lowercased.
|
|
||||||
fn calculate_text_similarity(query: &str, text: &str) -> Option<f64> {
|
|
||||||
if query.is_empty() || text.is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
if text == query {
|
|
||||||
return Some(1.0); // Perfect match
|
|
||||||
}
|
|
||||||
|
|
||||||
let query_len = query.len() as f64;
|
|
||||||
let text_len = text.len() as f64;
|
|
||||||
let ratio = query_len / text_len;
|
|
||||||
let mut score: f64 = 0.0;
|
|
||||||
|
|
||||||
// Case 1: Text starts with the query (prefix match)
|
|
||||||
// Score: base 0.5, bonus up to 0.4 for how much of `text` is covered by `query`. Max 0.9.
|
|
||||||
if text.starts_with(query) {
|
|
||||||
score = score.max(0.5 + 0.4 * ratio);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Case 2: Text contains the query (substring match, not necessarily prefix)
|
|
||||||
// Score: base 0.3, bonus up to 0.3. Max 0.6.
|
|
||||||
// `score.max` ensures that if it's both a prefix and contains, the higher score (prefix) is taken.
|
|
||||||
if text.contains(query) {
|
|
||||||
score = score.max(0.3 + 0.3 * ratio);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Case 3: Fallback for "all query characters exist in text" (order-independent)
|
|
||||||
if score < 0.2 {
|
|
||||||
if query.chars().all(|c_q| text.contains(c_q)) {
|
|
||||||
score = score.max(0.15); // Fixed low score for this weaker match type
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if score > 0.0 {
|
|
||||||
// Cap non-perfect matches slightly below 1.0 to make perfect (1.0) distinct.
|
|
||||||
Some(score.min(0.95))
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub(crate) async fn uninstall_extension(
|
|
||||||
tauri_app_handle: AppHandle,
|
|
||||||
developer: String,
|
|
||||||
extension_id: String,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE
|
|
||||||
.get()
|
|
||||||
.expect("global third party search source not set")
|
|
||||||
.uninstall_extension(&tauri_app_handle, &developer, &extension_id)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
// Helper function for approximate floating point comparison
|
|
||||||
fn approx_eq(a: f64, b: f64) -> bool {
|
|
||||||
(a - b).abs() < 1e-10
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_empty_strings() {
|
|
||||||
assert_eq!(calculate_text_similarity("", "text"), None);
|
|
||||||
assert_eq!(calculate_text_similarity("query", ""), None);
|
|
||||||
assert_eq!(calculate_text_similarity("", ""), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_perfect_match() {
|
|
||||||
assert_eq!(calculate_text_similarity("text", "text"), Some(1.0));
|
|
||||||
assert_eq!(calculate_text_similarity("a", "a"), Some(1.0));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_prefix_match() {
|
|
||||||
// For "te" and "text":
|
|
||||||
// score = 0.5 + 0.4 * (2/4) = 0.5 + 0.2 = 0.7
|
|
||||||
let score = calculate_text_similarity("te", "text").unwrap();
|
|
||||||
assert!(approx_eq(score, 0.7));
|
|
||||||
|
|
||||||
// For "tex" and "text":
|
|
||||||
// score = 0.5 + 0.4 * (3/4) = 0.5 + 0.3 = 0.8
|
|
||||||
let score = calculate_text_similarity("tex", "text").unwrap();
|
|
||||||
assert!(approx_eq(score, 0.8));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_substring_match() {
|
|
||||||
// For "ex" and "text":
|
|
||||||
// score = 0.3 + 0.3 * (2/4) = 0.3 + 0.15 = 0.45
|
|
||||||
let score = calculate_text_similarity("ex", "text").unwrap();
|
|
||||||
assert!(approx_eq(score, 0.45));
|
|
||||||
|
|
||||||
// Prefix should score higher than substring
|
|
||||||
assert!(
|
|
||||||
calculate_text_similarity("te", "text").unwrap()
|
|
||||||
> calculate_text_similarity("ex", "text").unwrap()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_character_presence() {
|
|
||||||
// Characters present but not in sequence
|
|
||||||
// "tac" in "contact" - not a substring, but all chars exist
|
|
||||||
let score = calculate_text_similarity("tac", "contact").unwrap();
|
|
||||||
assert!(approx_eq(0.3 + 0.3 * (3.0 / 7.0), score));
|
|
||||||
|
|
||||||
assert!(calculate_text_similarity("ac", "contact").is_some());
|
|
||||||
|
|
||||||
// Should not apply if some characters are missing
|
|
||||||
assert_eq!(calculate_text_similarity("xyz", "contact"), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_combined_scenarios() {
|
|
||||||
// Test that character presence fallback doesn't override higher scores
|
|
||||||
// "tex" is a prefix of "text" with score 0.8
|
|
||||||
let score = calculate_text_similarity("tex", "text").unwrap();
|
|
||||||
assert!(approx_eq(score, 0.8));
|
|
||||||
|
|
||||||
// Test a case where the characters exist but it's already a substring
|
|
||||||
// "act" is a substring of "contact" with score > 0.2, so fallback won't apply
|
|
||||||
let expected_score = 0.3 + 0.3 * (3.0 / 7.0);
|
|
||||||
let actual_score = calculate_text_similarity("act", "contact").unwrap();
|
|
||||||
assert!(approx_eq(actual_score, expected_score));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_no_similarity() {
|
|
||||||
assert_eq!(calculate_text_similarity("xyz", "test"), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_score_capping() {
|
|
||||||
// Use a long query that's a prefix of a slightly longer text
|
|
||||||
let long_text = "abcdefghijklmnopqrstuvwxyz";
|
|
||||||
let long_prefix = "abcdefghijklmnopqrstuvwxy"; // All but last letter
|
|
||||||
|
|
||||||
// Expected score would be 0.5 + 0.4 * (25/26) = 0.5 + 0.385 = 0.885
|
|
||||||
let expected_score = 0.5 + 0.4 * (25.0 / 26.0);
|
|
||||||
let actual_score = calculate_text_similarity(long_prefix, long_text).unwrap();
|
|
||||||
assert!(approx_eq(actual_score, expected_score));
|
|
||||||
|
|
||||||
// Verify that non-perfect matches are capped at 0.95
|
|
||||||
assert!(calculate_text_similarity("almost", "almost perfect").unwrap() <= 0.95);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
38
src-tauri/src/extension/view_extension.rs
Normal file
38
src-tauri/src/extension/view_extension.rs
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
//! View extension-related stuff
|
||||||
|
|
||||||
|
use actix_files::Files;
|
||||||
|
use actix_web::{App, HttpServer, dev::ServerHandle};
|
||||||
|
use std::path::Path;
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
|
static FILE_SERVER_HANDLE: Mutex<Option<ServerHandle>> = Mutex::const_new(None);
|
||||||
|
|
||||||
|
/// Start a static HTTP file server serving the directory specified by `path`.
|
||||||
|
/// Return the URL of the server.
|
||||||
|
pub(crate) async fn serve_files_in(path: &Path) -> String {
|
||||||
|
const ADDR: &str = "127.0.0.1";
|
||||||
|
|
||||||
|
let mut guard = FILE_SERVER_HANDLE.lock().await;
|
||||||
|
if let Some(prev_server_handle) = guard.take() {
|
||||||
|
prev_server_handle.stop(true).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = path.to_path_buf();
|
||||||
|
let http_server =
|
||||||
|
HttpServer::new(move || App::new().service(Files::new("/", &path).show_files_listing()))
|
||||||
|
// Set port to 0 and let OS assign a port to us
|
||||||
|
.bind((ADDR, 0))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let assigned_port = http_server.addrs()[0].port();
|
||||||
|
|
||||||
|
let server = http_server.disable_signals().workers(1).run();
|
||||||
|
|
||||||
|
let new_handle = server.handle();
|
||||||
|
|
||||||
|
tokio::spawn(server);
|
||||||
|
|
||||||
|
*guard = Some(new_handle);
|
||||||
|
|
||||||
|
format!("http://{}:{}", ADDR, assigned_port)
|
||||||
|
}
|
||||||
@@ -7,21 +7,26 @@ mod server;
|
|||||||
mod settings;
|
mod settings;
|
||||||
mod setup;
|
mod setup;
|
||||||
mod shortcut;
|
mod shortcut;
|
||||||
mod util;
|
// We need this in main.rs, so it has to be pub
|
||||||
|
pub mod util;
|
||||||
|
|
||||||
use crate::common::register::SearchSourceRegistry;
|
use crate::common::register::SearchSourceRegistry;
|
||||||
use crate::common::{CHECK_WINDOW_LABEL, MAIN_WINDOW_LABEL, SETTINGS_WINDOW_LABEL};
|
use crate::common::{CHECK_WINDOW_LABEL, MAIN_WINDOW_LABEL, SETTINGS_WINDOW_LABEL};
|
||||||
use crate::server::servers::{load_or_insert_default_server, load_servers_token};
|
use crate::server::servers::{load_or_insert_default_server, load_servers_token};
|
||||||
|
use crate::util::logging::set_up_tauri_logger;
|
||||||
|
use crate::util::prevent_default;
|
||||||
use autostart::change_autostart;
|
use autostart::change_autostart;
|
||||||
use lazy_static::lazy_static;
|
use lazy_static::lazy_static;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
use std::sync::OnceLock;
|
use std::sync::OnceLock;
|
||||||
use tauri::plugin::TauriPlugin;
|
use tauri::{
|
||||||
use tauri::{AppHandle, Emitter, Manager, PhysicalPosition, WebviewWindow, WindowEvent};
|
AppHandle, Emitter, LogicalPosition, Manager, PhysicalPosition, WebviewWindow, WindowEvent,
|
||||||
|
};
|
||||||
use tauri_plugin_autostart::MacosLauncher;
|
use tauri_plugin_autostart::MacosLauncher;
|
||||||
|
|
||||||
/// Tauri store name
|
/// Tauri store name
|
||||||
pub(crate) const COCO_TAURI_STORE: &str = "coco_tauri_store";
|
pub(crate) const COCO_TAURI_STORE: &str = "coco_tauri_store";
|
||||||
|
pub(crate) const WINDOW_CENTER_BASELINE_HEIGHT: i32 = 590;
|
||||||
|
|
||||||
lazy_static! {
|
lazy_static! {
|
||||||
static ref PREVIOUS_MONITOR_NAME: Mutex<Option<String>> = Mutex::new(None);
|
static ref PREVIOUS_MONITOR_NAME: Mutex<Option<String>> = Mutex::new(None);
|
||||||
@@ -43,6 +48,26 @@ async fn change_window_height(handle: AppHandle, height: u32) {
|
|||||||
let mut size = window.outer_size().unwrap();
|
let mut size = window.outer_size().unwrap();
|
||||||
size.height = height;
|
size.height = height;
|
||||||
window.set_size(size).unwrap();
|
window.set_size(size).unwrap();
|
||||||
|
|
||||||
|
// Center the window horizontally and vertically based on the baseline height of 590
|
||||||
|
let monitor = window.primary_monitor().ok().flatten().or_else(|| {
|
||||||
|
window
|
||||||
|
.available_monitors()
|
||||||
|
.ok()
|
||||||
|
.and_then(|ms| ms.into_iter().next())
|
||||||
|
});
|
||||||
|
if let Some(monitor) = monitor {
|
||||||
|
let monitor_position = monitor.position();
|
||||||
|
let monitor_size = monitor.size();
|
||||||
|
|
||||||
|
let window_width = window.outer_size().unwrap().width as i32;
|
||||||
|
let x = monitor_position.x + (monitor_size.width as i32 - window_width) / 2;
|
||||||
|
|
||||||
|
let y =
|
||||||
|
monitor_position.y + (monitor_size.height as i32 - WINDOW_CENTER_BASELINE_HEIGHT) / 2;
|
||||||
|
|
||||||
|
let _ = window.set_position(PhysicalPosition::new(x, y));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(serde::Deserialize)]
|
#[derive(serde::Deserialize)]
|
||||||
@@ -88,11 +113,12 @@ pub fn run() {
|
|||||||
.plugin(tauri_plugin_process::init())
|
.plugin(tauri_plugin_process::init())
|
||||||
.plugin(
|
.plugin(
|
||||||
tauri_plugin_updater::Builder::new()
|
tauri_plugin_updater::Builder::new()
|
||||||
.default_version_comparator(crate::util::updater::custom_version_comparator)
|
.default_version_comparator(crate::util::version::custom_version_comparator)
|
||||||
.build(),
|
.build(),
|
||||||
)
|
)
|
||||||
.plugin(tauri_plugin_windows_version::init())
|
.plugin(tauri_plugin_windows_version::init())
|
||||||
.plugin(tauri_plugin_opener::init());
|
.plugin(tauri_plugin_opener::init())
|
||||||
|
.plugin(prevent_default::init());
|
||||||
|
|
||||||
// Conditional compilation for macOS
|
// Conditional compilation for macOS
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
@@ -164,18 +190,23 @@ pub fn run() {
|
|||||||
extension::third_party::install::store::install_extension_from_store,
|
extension::third_party::install::store::install_extension_from_store,
|
||||||
extension::third_party::install::local_extension::install_local_extension,
|
extension::third_party::install::local_extension::install_local_extension,
|
||||||
extension::third_party::uninstall_extension,
|
extension::third_party::uninstall_extension,
|
||||||
|
extension::is_extension_compatible,
|
||||||
|
extension::api::apis,
|
||||||
|
extension::api::fs::read_dir,
|
||||||
settings::set_allow_self_signature,
|
settings::set_allow_self_signature,
|
||||||
settings::get_allow_self_signature,
|
settings::get_allow_self_signature,
|
||||||
|
settings::set_local_query_source_weight,
|
||||||
|
settings::get_local_query_source_weight,
|
||||||
assistant::ask_ai,
|
assistant::ask_ai,
|
||||||
crate::common::document::open,
|
crate::common::document::open,
|
||||||
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
|
||||||
extension::built_in::file_search::config::get_file_system_config,
|
extension::built_in::file_search::config::get_file_system_config,
|
||||||
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
|
||||||
extension::built_in::file_search::config::set_file_system_config,
|
extension::built_in::file_search::config::set_file_system_config,
|
||||||
server::synthesize::synthesize,
|
server::synthesize::synthesize,
|
||||||
util::file::get_file_icon,
|
util::file::get_file_icon,
|
||||||
setup::backend_setup,
|
setup::backend_setup,
|
||||||
util::app_lang::update_app_lang,
|
util::app_lang::update_app_lang,
|
||||||
|
util::path::path_absolute,
|
||||||
|
util::logging::app_log_dir
|
||||||
])
|
])
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
@@ -262,117 +293,112 @@ async fn show_coco(app_handle: AppHandle) {
|
|||||||
if let Some(window) = app_handle.get_webview_window(MAIN_WINDOW_LABEL) {
|
if let Some(window) = app_handle.get_webview_window(MAIN_WINDOW_LABEL) {
|
||||||
move_window_to_active_monitor(&window);
|
move_window_to_active_monitor(&window);
|
||||||
|
|
||||||
let _ = window.show();
|
cfg_if::cfg_if! {
|
||||||
let _ = window.unminimize();
|
if #[cfg(target_os = "macos")] {
|
||||||
let _ = window.set_focus();
|
use tauri_nspanel::ManagerExt;
|
||||||
|
|
||||||
|
let app_handle_clone = app_handle.clone();
|
||||||
|
|
||||||
|
app_handle.run_on_main_thread(move || {
|
||||||
|
let panel = app_handle_clone.get_webview_panel(MAIN_WINDOW_LABEL).unwrap();
|
||||||
|
|
||||||
|
panel.show_and_make_key();
|
||||||
|
}).unwrap();
|
||||||
|
} else {
|
||||||
|
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.
|
||||||
|
let _ = window.set_focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let _ = app_handle.emit("show-coco", ());
|
let _ = app_handle.emit("show-coco", ());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn hide_coco(app: AppHandle) {
|
async fn hide_coco(app_handle: AppHandle) {
|
||||||
if let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) {
|
cfg_if::cfg_if! {
|
||||||
if let Err(err) = window.hide() {
|
if #[cfg(target_os = "macos")] {
|
||||||
log::error!("Failed to hide the window: {}", err);
|
use tauri_nspanel::ManagerExt;
|
||||||
|
|
||||||
|
let app_handle_clone = app_handle.clone();
|
||||||
|
app_handle.run_on_main_thread(move || {
|
||||||
|
let panel = app_handle_clone.get_webview_panel(MAIN_WINDOW_LABEL).expect("cannot find the main window/panel");
|
||||||
|
panel.hide();
|
||||||
|
}).unwrap();
|
||||||
} else {
|
} else {
|
||||||
log::debug!("Window successfully hidden.");
|
let window = app_handle.get_webview_window(MAIN_WINDOW_LABEL).expect("cannot find the main window");
|
||||||
|
|
||||||
|
if let Err(err) = window.hide() {
|
||||||
|
log::error!("Failed to hide the window: {}", err);
|
||||||
|
} else {
|
||||||
|
log::debug!("Window successfully hidden.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
};
|
||||||
log::error!("Main window not found.");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn move_window_to_active_monitor(window: &WebviewWindow) {
|
fn move_window_to_active_monitor(window: &WebviewWindow) {
|
||||||
//dbg!("Moving window to active monitor");
|
let scale_factor = window.scale_factor().unwrap();
|
||||||
// Try to get the available monitors, handle failure gracefully
|
|
||||||
let available_monitors = match window.available_monitors() {
|
|
||||||
Ok(monitors) => monitors,
|
|
||||||
Err(e) => {
|
|
||||||
log::error!("Failed to get monitors: {}", e);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Attempt to get the cursor position, handle failure gracefully
|
let point = window.cursor_position().unwrap();
|
||||||
let cursor_position = match window.cursor_position() {
|
|
||||||
Ok(pos) => Some(pos),
|
|
||||||
Err(e) => {
|
|
||||||
log::error!("Failed to get cursor position: {}", e);
|
|
||||||
None
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Find the monitor that contains the cursor or default to the primary monitor
|
let LogicalPosition { x, y } = point.to_logical(scale_factor);
|
||||||
let target_monitor = if let Some(cursor_position) = cursor_position {
|
|
||||||
// Convert cursor position to integers
|
match window.monitor_from_point(x, y) {
|
||||||
let cursor_x = cursor_position.x.round() as i32;
|
Ok(Some(monitor)) => {
|
||||||
let cursor_y = cursor_position.y.round() as i32;
|
if let Some(name) = monitor.name() {
|
||||||
|
let previous_monitor_name = PREVIOUS_MONITOR_NAME.lock().unwrap();
|
||||||
|
if let Some(ref prev_name) = *previous_monitor_name {
|
||||||
|
if name.to_string() == *prev_name {
|
||||||
|
log::debug!("Currently on the same monitor");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Find the monitor that contains the cursor
|
|
||||||
available_monitors.into_iter().find(|monitor| {
|
|
||||||
let monitor_position = monitor.position();
|
let monitor_position = monitor.position();
|
||||||
let monitor_size = monitor.size();
|
let monitor_size = monitor.size();
|
||||||
|
|
||||||
cursor_x >= monitor_position.x
|
// Current window size for horizontal centering
|
||||||
&& cursor_x <= monitor_position.x + monitor_size.width as i32
|
let window_size = match window.inner_size() {
|
||||||
&& cursor_y >= monitor_position.y
|
Ok(size) => size,
|
||||||
&& cursor_y <= monitor_position.y + monitor_size.height as i32
|
Err(e) => {
|
||||||
})
|
log::error!("Failed to get window size: {}", e);
|
||||||
} else {
|
return;
|
||||||
None
|
}
|
||||||
};
|
};
|
||||||
|
let window_width = window_size.width as i32;
|
||||||
|
|
||||||
// Use the target monitor or default to the primary monitor
|
// Horizontal center uses actual width, vertical center uses 590 baseline
|
||||||
let monitor = match target_monitor.or_else(|| window.primary_monitor().ok().flatten()) {
|
let window_x = monitor_position.x + (monitor_size.width as i32 - window_width) / 2;
|
||||||
Some(monitor) => monitor,
|
let window_y = monitor_position.y
|
||||||
None => {
|
+ (monitor_size.height as i32 - WINDOW_CENTER_BASELINE_HEIGHT) / 2;
|
||||||
log::error!("No monitor found!");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Some(name) = monitor.name() {
|
if let Err(e) = window.set_position(PhysicalPosition::new(window_x, window_y)) {
|
||||||
let previous_monitor_name = PREVIOUS_MONITOR_NAME.lock().unwrap();
|
log::error!("Failed to move window: {}", e);
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(ref prev_name) = *previous_monitor_name {
|
if let Some(name) = monitor.name() {
|
||||||
if name.to_string() == *prev_name {
|
log::debug!("Window moved to monitor: {}", name);
|
||||||
log::debug!("Currently on the same monitor");
|
let mut previous_monitor = PREVIOUS_MONITOR_NAME.lock().unwrap();
|
||||||
|
*previous_monitor = Some(name.to_string());
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
Ok(None) => {
|
||||||
|
log::error!("No monitor found at the specified point");
|
||||||
let monitor_position = monitor.position();
|
}
|
||||||
let monitor_size = monitor.size();
|
Err(e) => {
|
||||||
|
log::error!("Failed to get monitor from point: {}", e);
|
||||||
// Get the current size of the window
|
|
||||||
let window_size = match window.inner_size() {
|
|
||||||
Ok(size) => size,
|
|
||||||
Err(e) => {
|
|
||||||
log::error!("Failed to get window size: {}", e);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
let window_width = window_size.width as i32;
|
|
||||||
let window_height = window_size.height as i32;
|
|
||||||
|
|
||||||
// Calculate the new position to center the window on the monitor
|
|
||||||
let window_x = monitor_position.x + (monitor_size.width as i32 - window_width) / 2;
|
|
||||||
let window_y = monitor_position.y + (monitor_size.height as i32 - window_height) / 2;
|
|
||||||
|
|
||||||
// Move the window to the new position
|
|
||||||
if let Err(e) = window.set_position(PhysicalPosition::new(window_x, window_y)) {
|
|
||||||
log::error!("Failed to move window: {}", e);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(name) = monitor.name() {
|
|
||||||
log::debug!("Window moved to monitor: {}", name);
|
|
||||||
|
|
||||||
let mut previous_monitor = PREVIOUS_MONITOR_NAME.lock().unwrap();
|
|
||||||
*previous_monitor = Some(name.to_string());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -417,135 +443,3 @@ async fn hide_check(app_handle: AppHandle) {
|
|||||||
|
|
||||||
window.hide().unwrap();
|
window.hide().unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Log format:
|
|
||||||
///
|
|
||||||
/// ```text
|
|
||||||
/// [time] [log level] [file module:line] message
|
|
||||||
/// ```
|
|
||||||
///
|
|
||||||
/// Example:
|
|
||||||
///
|
|
||||||
///
|
|
||||||
/// ```text
|
|
||||||
/// [05-11 17:00:00] [INF] [coco_lib:625] Coco-AI started
|
|
||||||
/// ```
|
|
||||||
fn set_up_tauri_logger() -> TauriPlugin<tauri::Wry> {
|
|
||||||
use log::Level;
|
|
||||||
use log::LevelFilter;
|
|
||||||
use tauri_plugin_log::Builder;
|
|
||||||
|
|
||||||
/// Coco-AI app's default log level.
|
|
||||||
const DEFAULT_LOG_LEVEL: LevelFilter = LevelFilter::Info;
|
|
||||||
const LOG_LEVEL_ENV_VAR: &str = "COCO_LOG";
|
|
||||||
|
|
||||||
fn format_log_level(level: Level) -> &'static str {
|
|
||||||
match level {
|
|
||||||
Level::Trace => "TRC",
|
|
||||||
Level::Debug => "DBG",
|
|
||||||
Level::Info => "INF",
|
|
||||||
Level::Warn => "WAR",
|
|
||||||
Level::Error => "ERR",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn format_target_and_line(record: &log::Record) -> String {
|
|
||||||
let mut str = record.target().to_string();
|
|
||||||
if let Some(line) = record.line() {
|
|
||||||
str.push(':');
|
|
||||||
str.push_str(&line.to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
str
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Allow us to configure dynamic log levels via environment variable `COCO_LOG`.
|
|
||||||
///
|
|
||||||
/// Generally, it mirros the behavior of `env_logger`. Syntax: `COCO_LOG=[target][=][level][,...]`
|
|
||||||
///
|
|
||||||
/// * If this environment variable is not set, use the default log level.
|
|
||||||
/// * If it is set, respect it:
|
|
||||||
///
|
|
||||||
/// * `COCO_LOG=coco_lib` turns on all logging for the `coco_lib` module, which is
|
|
||||||
/// equivalent to `COCO_LOG=coco_lib=trace`
|
|
||||||
/// * `COCO_LOG=trace` turns on all logging for the application, regardless of its name
|
|
||||||
/// * `COCO_LOG=TRACE` turns on all logging for the application, regardless of its name (same as previous)
|
|
||||||
/// * `COCO_LOG=reqwest=debug` turns on debug logging for `reqwest`
|
|
||||||
/// * `COCO_LOG=trace,tauri=off` turns on all the logging except for the logs come from `tauri`
|
|
||||||
/// * `COCO_LOG=off` turns off all logging for the application
|
|
||||||
/// * `COCO_LOG=` Since the value is empty, turns off all logging for the application as well
|
|
||||||
fn dynamic_log_level(mut builder: Builder) -> Builder {
|
|
||||||
let Some(log_levels) = std::env::var_os(LOG_LEVEL_ENV_VAR) else {
|
|
||||||
return builder.level(DEFAULT_LOG_LEVEL);
|
|
||||||
};
|
|
||||||
|
|
||||||
builder = builder.level(LevelFilter::Off);
|
|
||||||
|
|
||||||
let log_levels = log_levels.into_string().unwrap_or_else(|e| {
|
|
||||||
panic!(
|
|
||||||
"The value '{}' set in environment varaible '{}' is not UTF-8 encoded",
|
|
||||||
// Cannot use `.display()` here becuase that requires MSRV 1.87.0
|
|
||||||
e.to_string_lossy(),
|
|
||||||
LOG_LEVEL_ENV_VAR
|
|
||||||
)
|
|
||||||
});
|
|
||||||
|
|
||||||
// COCO_LOG=[target][=][level][,...]
|
|
||||||
let target_log_levels = log_levels.split(',');
|
|
||||||
for target_log_level in target_log_levels {
|
|
||||||
#[allow(clippy::collapsible_else_if)]
|
|
||||||
if let Some(char_index) = target_log_level.chars().position(|c| c == '=') {
|
|
||||||
let (target, equal_sign_and_level) = target_log_level.split_at(char_index);
|
|
||||||
// Remove the equal sign, we know it takes 1 byte
|
|
||||||
let level = &equal_sign_and_level[1..];
|
|
||||||
|
|
||||||
if let Ok(level) = level.parse::<LevelFilter>() {
|
|
||||||
// Here we have to call `.to_string()` because `Cow<'static, str>` requires `&'static str`
|
|
||||||
builder = builder.level_for(target.to_string(), level);
|
|
||||||
} else {
|
|
||||||
panic!(
|
|
||||||
"log level '{}' set in '{}={}' is invalid",
|
|
||||||
level, target, level
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if let Ok(level) = target_log_level.parse::<LevelFilter>() {
|
|
||||||
// This is a level
|
|
||||||
builder = builder.level(level);
|
|
||||||
} else {
|
|
||||||
// This is a target, enable all the logging
|
|
||||||
//
|
|
||||||
// Here we have to call `.to_string()` because `Cow<'static, str>` requires `&'static str`
|
|
||||||
builder = builder.level_for(target_log_level.to_string(), LevelFilter::Trace);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
builder
|
|
||||||
}
|
|
||||||
|
|
||||||
// When running the built binary, set `COCO_LOG` to `coco_lib=trace` to capture all logs
|
|
||||||
// that come from Coco in the log file, which helps with debugging.
|
|
||||||
if !tauri::is_dev() {
|
|
||||||
// We have absolutely no guarantee that we (We have control over the Rust
|
|
||||||
// code, but definitely no idea about the libc C code, all the shared objects
|
|
||||||
// that we will link) will not concurrently read/write `envp`, so just use unsafe.
|
|
||||||
unsafe {
|
|
||||||
std::env::set_var("COCO_LOG", "coco_lib=trace");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut builder = tauri_plugin_log::Builder::new();
|
|
||||||
builder = builder.format(|out, message, record| {
|
|
||||||
let now = chrono::Local::now().format("%m-%d %H:%M:%S");
|
|
||||||
let level = format_log_level(record.level());
|
|
||||||
let target_and_line = format_target_and_line(record);
|
|
||||||
out.finish(format_args!(
|
|
||||||
"[{}] [{}] [{}] {}",
|
|
||||||
now, level, target_and_line, message
|
|
||||||
));
|
|
||||||
});
|
|
||||||
builder = dynamic_log_level(builder);
|
|
||||||
|
|
||||||
builder.build()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,42 +1,9 @@
|
|||||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
|
use coco_lib::util::logging::app_log_dir;
|
||||||
use std::fs::OpenOptions;
|
use std::fs::OpenOptions;
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
/// Helper function to return the log directory.
|
|
||||||
///
|
|
||||||
/// This should return the same value as `tauri_app_handle.path().app_log_dir().unwrap()`.
|
|
||||||
fn app_log_dir() -> PathBuf {
|
|
||||||
// This function `app_log_dir()` is for the panic hook, which should be set
|
|
||||||
// before Tauri performs any initialization. At that point, we do not have
|
|
||||||
// access to the identifier provided by Tauri, so we need to define our own
|
|
||||||
// one here.
|
|
||||||
//
|
|
||||||
// NOTE: If you update identifier in the following files, update this one
|
|
||||||
// as well!
|
|
||||||
//
|
|
||||||
// src-tauri/tauri.linux.conf.json
|
|
||||||
// src-tauri/Entitlements.plist
|
|
||||||
// src-tauri/tauri.conf.json
|
|
||||||
// src-tauri/Info.plist
|
|
||||||
const IDENTIFIER: &str = "rs.coco.app";
|
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
let path = dirs::home_dir()
|
|
||||||
.expect("cannot find the home directory, Coco should never run in such a environment")
|
|
||||||
.join("Library/Logs")
|
|
||||||
.join(IDENTIFIER);
|
|
||||||
|
|
||||||
#[cfg(not(target_os = "macos"))]
|
|
||||||
let path = dirs::data_local_dir()
|
|
||||||
.expect("app local dir is None, we should not encounter this")
|
|
||||||
.join(IDENTIFIER)
|
|
||||||
.join("logs");
|
|
||||||
|
|
||||||
path
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set up panic hook to log panic information to a file
|
/// Set up panic hook to log panic information to a file
|
||||||
fn setup_panic_hook() {
|
fn setup_panic_hook() {
|
||||||
|
|||||||
@@ -4,19 +4,18 @@ use crate::common::search::{
|
|||||||
FailedRequest, MultiSourceQueryResponse, QueryHits, QuerySource, SearchQuery,
|
FailedRequest, MultiSourceQueryResponse, QueryHits, QuerySource, SearchQuery,
|
||||||
};
|
};
|
||||||
use crate::common::traits::SearchSource;
|
use crate::common::traits::SearchSource;
|
||||||
|
use crate::extension::LOCAL_QUERY_SOURCE_TYPE;
|
||||||
use crate::server::servers::logout_coco_server;
|
use crate::server::servers::logout_coco_server;
|
||||||
use crate::server::servers::mark_server_as_offline;
|
use crate::server::servers::mark_server_as_offline;
|
||||||
|
use crate::settings::get_local_query_source_weight;
|
||||||
use function_name::named;
|
use function_name::named;
|
||||||
use futures::StreamExt;
|
use futures::StreamExt;
|
||||||
use futures::stream::FuturesUnordered;
|
use futures::stream::FuturesUnordered;
|
||||||
use reqwest::StatusCode;
|
use reqwest::StatusCode;
|
||||||
use std::cmp::Reverse;
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::collections::HashSet;
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tauri::{AppHandle, Manager};
|
use tauri::{AppHandle, Manager};
|
||||||
use tokio::time::{Duration, timeout};
|
use tokio::time::{Duration, timeout};
|
||||||
|
|
||||||
#[named]
|
#[named]
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn query_coco_fusion(
|
pub async fn query_coco_fusion(
|
||||||
@@ -187,7 +186,6 @@ async fn query_coco_fusion_multi_query_sources(
|
|||||||
|
|
||||||
let mut futures = FuturesUnordered::new();
|
let mut futures = FuturesUnordered::new();
|
||||||
|
|
||||||
let query_source_list_len = query_source_trait_object_list.len();
|
|
||||||
for query_source_trait_object in query_source_trait_object_list {
|
for query_source_trait_object in query_source_trait_object_list {
|
||||||
let query_source = query_source_trait_object.get_type().clone();
|
let query_source = query_source_trait_object.get_type().clone();
|
||||||
let tauri_app_handle_clone = tauri_app_handle.clone();
|
let tauri_app_handle_clone = tauri_app_handle.clone();
|
||||||
@@ -208,14 +206,8 @@ async fn query_coco_fusion_multi_query_sources(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mut total_hits = 0;
|
let mut total_hits = 0;
|
||||||
let mut need_rerank = true; //TODO set default to false when boost supported in Pizza
|
|
||||||
let mut failed_requests = Vec::new();
|
let mut failed_requests = Vec::new();
|
||||||
let mut all_hits: Vec<(String, QueryHits, f64)> = Vec::new();
|
let mut all_hits_grouped_by_query_source: HashMap<QuerySource, Vec<QueryHits>> = HashMap::new();
|
||||||
let mut hits_per_source: HashMap<String, Vec<(QueryHits, f64)>> = HashMap::new();
|
|
||||||
|
|
||||||
if query_source_list_len > 1 {
|
|
||||||
need_rerank = true; // If we have more than one source, we need to rerank the hits
|
|
||||||
}
|
|
||||||
|
|
||||||
while let Some((query_source, timeout_result)) = futures.next().await {
|
while let Some((query_source, timeout_result)) = futures.next().await {
|
||||||
match timeout_result {
|
match timeout_result {
|
||||||
@@ -229,7 +221,6 @@ async fn query_coco_fusion_multi_query_sources(
|
|||||||
Ok(query_result) => match query_result {
|
Ok(query_result) => match query_result {
|
||||||
Ok(response) => {
|
Ok(response) => {
|
||||||
total_hits += response.total_hits;
|
total_hits += response.total_hits;
|
||||||
let source_id = response.source.id.clone();
|
|
||||||
|
|
||||||
for (document, score) in response.hits {
|
for (document, score) in response.hits {
|
||||||
log::debug!(
|
log::debug!(
|
||||||
@@ -246,12 +237,10 @@ async fn query_coco_fusion_multi_query_sources(
|
|||||||
document,
|
document,
|
||||||
};
|
};
|
||||||
|
|
||||||
all_hits.push((source_id.clone(), query_hit.clone(), score));
|
all_hits_grouped_by_query_source
|
||||||
|
.entry(query_source.clone())
|
||||||
hits_per_source
|
|
||||||
.entry(source_id.clone())
|
|
||||||
.or_insert_with(Vec::new)
|
.or_insert_with(Vec::new)
|
||||||
.push((query_hit, score));
|
.push(query_hit);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(search_error) => {
|
Err(search_error) => {
|
||||||
@@ -267,109 +256,129 @@ async fn query_coco_fusion_multi_query_sources(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort hits within each source by score (descending)
|
let n_sources = all_hits_grouped_by_query_source.len();
|
||||||
for hits in hits_per_source.values_mut() {
|
|
||||||
hits.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Greater));
|
if n_sources == 0 {
|
||||||
|
return Ok(MultiSourceQueryResponse {
|
||||||
|
failed: Vec::new(),
|
||||||
|
hits: Vec::new(),
|
||||||
|
total_hits: 0,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let total_sources = hits_per_source.len();
|
/*
|
||||||
let max_hits_per_source = if total_sources > 0 {
|
* Apply settings: local query source weight
|
||||||
size as usize / total_sources
|
*/
|
||||||
} else {
|
let local_query_source_weight: f64 = get_local_query_source_weight(tauri_app_handle);
|
||||||
size as usize
|
// Scores remain unchanged if it is 1.0
|
||||||
};
|
if local_query_source_weight != 1.0 {
|
||||||
|
for (query_source, hits) in all_hits_grouped_by_query_source.iter_mut() {
|
||||||
|
if query_source.r#type == LOCAL_QUERY_SOURCE_TYPE {
|
||||||
|
hits.iter_mut()
|
||||||
|
.for_each(|hit| hit.score = hit.score * local_query_source_weight);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Sort hits within each source by score (descending) in case data sources
|
||||||
|
* do not sort them
|
||||||
|
*/
|
||||||
|
for hits in all_hits_grouped_by_query_source.values_mut() {
|
||||||
|
hits.sort_by(|a, b| {
|
||||||
|
b.score
|
||||||
|
.partial_cmp(&a.score)
|
||||||
|
.unwrap_or(std::cmp::Ordering::Greater)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Collect hits evenly across sources, to ensure:
|
||||||
|
*
|
||||||
|
* 1. All sources have hits returned
|
||||||
|
* 2. Query sources with many hits won't dominate
|
||||||
|
*/
|
||||||
|
let mut final_hits_grouped_by_source_id: HashMap<String, Vec<QueryHits>> = HashMap::new();
|
||||||
|
let mut pruned: HashMap<&str, &[QueryHits]> = HashMap::new();
|
||||||
|
|
||||||
|
// Include at least 2 hits from each query source
|
||||||
|
let max_hits_per_source = (size as usize / n_sources).max(2);
|
||||||
|
for (query_source, hits) in all_hits_grouped_by_query_source.iter() {
|
||||||
|
let hits_taken = if hits.len() > max_hits_per_source {
|
||||||
|
pruned.insert(&query_source.id, &hits[max_hits_per_source..]);
|
||||||
|
hits[0..max_hits_per_source].to_vec()
|
||||||
|
} else {
|
||||||
|
hits.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
final_hits_grouped_by_source_id.insert(query_source.id.clone(), hits_taken);
|
||||||
|
}
|
||||||
|
|
||||||
|
let final_hits_len = final_hits_grouped_by_source_id
|
||||||
|
.iter()
|
||||||
|
.fold(0, |acc: usize, (_source_id, hits)| acc + hits.len());
|
||||||
|
let pruned_len = pruned
|
||||||
|
.iter()
|
||||||
|
.fold(0, |acc: usize, (_source_id, hits)| acc + hits.len());
|
||||||
|
|
||||||
|
/*
|
||||||
|
* If we still need more hits, take the highest-scoring from `pruned`
|
||||||
|
*
|
||||||
|
* `pruned` contains sorted arrays, we scan it in a way similar to
|
||||||
|
* how n-way-merge-sort extracts the element with the greatest value.
|
||||||
|
*/
|
||||||
|
if final_hits_len < size as usize {
|
||||||
|
let n_need = size as usize - final_hits_len;
|
||||||
|
let n_have = pruned_len;
|
||||||
|
let n_take = n_have.min(n_need);
|
||||||
|
|
||||||
|
for _ in 0..n_take {
|
||||||
|
let mut highest_score_hit: Option<(&str, &QueryHits)> = None;
|
||||||
|
for (source_id, sorted_hits) in pruned.iter_mut() {
|
||||||
|
if sorted_hits.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let hit = &sorted_hits[0];
|
||||||
|
|
||||||
|
let have_higher_score_hit = match highest_score_hit {
|
||||||
|
Some((_, current_highest_score_hit)) => {
|
||||||
|
hit.score > current_highest_score_hit.score
|
||||||
|
}
|
||||||
|
None => true,
|
||||||
|
};
|
||||||
|
|
||||||
|
if have_higher_score_hit {
|
||||||
|
highest_score_hit = Some((*source_id, hit));
|
||||||
|
|
||||||
|
// Advance sorted_hits by 1 element, if have
|
||||||
|
if sorted_hits.len() == 1 {
|
||||||
|
*sorted_hits = &[];
|
||||||
|
} else {
|
||||||
|
*sorted_hits = &sorted_hits[1..];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let (source_id, hit) = highest_score_hit.expect("`pruned` should contain at least `n_take` elements so `highest_score_hit` should be set");
|
||||||
|
|
||||||
|
final_hits_grouped_by_source_id
|
||||||
|
.get_mut(source_id)
|
||||||
|
.expect("all the source_ids stored in `pruned` come from `final_hits_grouped_by_source_id`, so it should exist")
|
||||||
|
.push(hit.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Re-rank the final hits
|
||||||
|
*/
|
||||||
|
if n_sources > 1 {
|
||||||
|
boosted_levenshtein_rerank(&query_keyword, &mut final_hits_grouped_by_source_id);
|
||||||
|
}
|
||||||
|
|
||||||
let mut final_hits = Vec::new();
|
let mut final_hits = Vec::new();
|
||||||
let mut seen_docs = HashSet::new(); // To track documents we've already added
|
for (_source_id, hits) in final_hits_grouped_by_source_id {
|
||||||
|
final_hits.extend(hits);
|
||||||
// Distribute hits fairly across sources
|
|
||||||
for (_source_id, hits) in &mut hits_per_source {
|
|
||||||
let take_count = hits.len().min(max_hits_per_source);
|
|
||||||
for (doc, score) in hits.drain(0..take_count) {
|
|
||||||
if !seen_docs.contains(&doc.document.id) {
|
|
||||||
seen_docs.insert(doc.document.id.clone());
|
|
||||||
log::debug!(
|
|
||||||
"collect doc: {}, {:?}, {}",
|
|
||||||
doc.document.id,
|
|
||||||
doc.document.title,
|
|
||||||
score
|
|
||||||
);
|
|
||||||
final_hits.push(doc);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log::debug!("final hits: {:?}", final_hits.len());
|
|
||||||
|
|
||||||
let mut unique_sources = HashSet::new();
|
|
||||||
for hit in &final_hits {
|
|
||||||
if let Some(source) = &hit.source {
|
|
||||||
if source.id != crate::extension::built_in::calculator::DATA_SOURCE_ID {
|
|
||||||
unique_sources.insert(&source.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log::debug!(
|
|
||||||
"Multiple sources found: {:?}, no rerank needed",
|
|
||||||
unique_sources
|
|
||||||
);
|
|
||||||
|
|
||||||
if unique_sources.len() < 1 {
|
|
||||||
need_rerank = false; // If we have hits from multiple sources, we don't need to rerank
|
|
||||||
}
|
|
||||||
|
|
||||||
if need_rerank && final_hits.len() > 1 {
|
|
||||||
// Precollect (index, title)
|
|
||||||
let titles_to_score: Vec<(usize, &str)> = final_hits
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.filter_map(|(idx, hit)| {
|
|
||||||
let source = hit.source.as_ref()?;
|
|
||||||
let title = hit.document.title.as_deref()?;
|
|
||||||
|
|
||||||
if source.id != crate::extension::built_in::calculator::DATA_SOURCE_ID {
|
|
||||||
Some((idx, title))
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
// Score them
|
|
||||||
let scored_hits = boosted_levenshtein_rerank(query_keyword.as_str(), titles_to_score);
|
|
||||||
|
|
||||||
// Sort descending by score
|
|
||||||
let mut scored_hits = scored_hits;
|
|
||||||
scored_hits.sort_by_key(|&(_, score)| Reverse((score * 1000.0) as u64));
|
|
||||||
|
|
||||||
// Apply new scores to final_hits
|
|
||||||
for (idx, score) in scored_hits.into_iter().take(size as usize) {
|
|
||||||
final_hits[idx].score = score;
|
|
||||||
}
|
|
||||||
} else if final_hits.len() < size as usize {
|
|
||||||
// If we still need more hits, take the highest-scoring remaining ones
|
|
||||||
|
|
||||||
let remaining_needed = size as usize - final_hits.len();
|
|
||||||
|
|
||||||
// Sort all hits by score descending, removing duplicates by document ID
|
|
||||||
all_hits.sort_by(|a, b| b.2.partial_cmp(&a.2).unwrap_or(std::cmp::Ordering::Equal));
|
|
||||||
|
|
||||||
let extra_hits = all_hits
|
|
||||||
.into_iter()
|
|
||||||
.filter(|(source_id, _, _)| hits_per_source.contains_key(source_id)) // Only take from known sources
|
|
||||||
.filter_map(|(_, doc, _)| {
|
|
||||||
if !seen_docs.contains(&doc.document.id) {
|
|
||||||
seen_docs.insert(doc.document.id.clone());
|
|
||||||
Some(doc)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.take(remaining_needed)
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
|
|
||||||
final_hits.extend(extra_hits);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// **Sort final hits by score descending**
|
// **Sort final hits by score descending**
|
||||||
@@ -379,6 +388,9 @@ async fn query_coco_fusion_multi_query_sources(
|
|||||||
.unwrap_or(std::cmp::Ordering::Equal)
|
.unwrap_or(std::cmp::Ordering::Equal)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Truncate `final_hits` in case it contains more than `size` hits
|
||||||
|
final_hits.truncate(size as usize);
|
||||||
|
|
||||||
if final_hits.len() < 5 {
|
if final_hits.len() < 5 {
|
||||||
//TODO: Add a recommendation system to suggest more sources
|
//TODO: Add a recommendation system to suggest more sources
|
||||||
log::info!(
|
log::info!(
|
||||||
@@ -395,30 +407,85 @@ async fn query_coco_fusion_multi_query_sources(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn boosted_levenshtein_rerank(query: &str, titles: Vec<(usize, &str)>) -> Vec<(usize, f64)> {
|
use std::collections::HashSet;
|
||||||
use strsim::levenshtein;
|
use strsim::levenshtein;
|
||||||
|
|
||||||
|
fn boosted_levenshtein_rerank(
|
||||||
|
query: &str,
|
||||||
|
all_hits_grouped_by_source_id: &mut HashMap<String, Vec<QueryHits>>,
|
||||||
|
) {
|
||||||
let query_lower = query.to_lowercase();
|
let query_lower = query.to_lowercase();
|
||||||
|
|
||||||
titles
|
for (source_id, hits) in all_hits_grouped_by_source_id.iter_mut() {
|
||||||
.into_iter()
|
// Skip special sources like calculator
|
||||||
.map(|(idx, title)| {
|
if source_id == crate::extension::built_in::calculator::DATA_SOURCE_ID {
|
||||||
let mut score = 0.0;
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if title.contains(query) {
|
for hit in hits.iter_mut() {
|
||||||
score += 0.4;
|
let document_title = hit.document.title.as_deref().unwrap_or("");
|
||||||
} else if title.to_lowercase().contains(&query_lower) {
|
let document_title_lowercase = document_title.to_lowercase();
|
||||||
score += 0.2;
|
|
||||||
}
|
|
||||||
|
|
||||||
let dist = levenshtein(&query_lower, &title.to_lowercase());
|
let new_score = {
|
||||||
let max_len = query_lower.len().max(title.len());
|
let mut score = 0.0;
|
||||||
if max_len > 0 {
|
|
||||||
score += (1.0 - (dist as f64 / max_len as f64)) as f32;
|
|
||||||
}
|
|
||||||
|
|
||||||
(idx, score.min(1.0) as f64)
|
// --- Exact or substring boost ---
|
||||||
})
|
if document_title.contains(query) {
|
||||||
|
score += 0.4;
|
||||||
|
} else if document_title_lowercase.contains(&query_lower) {
|
||||||
|
score += 0.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Levenshtein distance (character similarity) ---
|
||||||
|
let dist = levenshtein(&query_lower, &document_title_lowercase);
|
||||||
|
let max_len = query_lower.len().max(document_title.len());
|
||||||
|
let levenshtein_score = if max_len > 0 {
|
||||||
|
(1.0 - (dist as f64 / max_len as f64)) as f32
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Jaccard similarity (token overlap) ---
|
||||||
|
let jaccard_score = jaccard_similarity(&query_lower, &document_title_lowercase);
|
||||||
|
|
||||||
|
// --- Combine scores (weights adjustable) ---
|
||||||
|
// Levenshtein emphasizes surface similarity
|
||||||
|
// Jaccard emphasizes term overlap (semantic hint)
|
||||||
|
let hybrid_score = 0.7 * levenshtein_score + 0.3 * jaccard_score;
|
||||||
|
|
||||||
|
// --- Apply hybrid score ---
|
||||||
|
score += hybrid_score;
|
||||||
|
|
||||||
|
// --- Limit score range ---
|
||||||
|
score.min(1.0) as f64
|
||||||
|
};
|
||||||
|
|
||||||
|
hit.score = new_score;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute token-based Jaccard similarity
|
||||||
|
fn jaccard_similarity(a: &str, b: &str) -> f32 {
|
||||||
|
let a_tokens: HashSet<_> = tokenize(a).into_iter().collect();
|
||||||
|
let b_tokens: HashSet<_> = tokenize(b).into_iter().collect();
|
||||||
|
|
||||||
|
if a_tokens.is_empty() || b_tokens.is_empty() {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let intersection = a_tokens.intersection(&b_tokens).count() as f32;
|
||||||
|
let union = a_tokens.union(&b_tokens).count() as f32;
|
||||||
|
|
||||||
|
intersection / union
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Basic tokenizer (case-insensitive, alphanumeric words only)
|
||||||
|
fn tokenize(text: &str) -> Vec<String> {
|
||||||
|
text.to_lowercase()
|
||||||
|
.split(|c: char| !c.is_alphanumeric())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(|s| s.to_string())
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use tauri::AppHandle;
|
|||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
fn request_access_token_url(request_id: &str) -> String {
|
fn request_access_token_url(request_id: &str) -> String {
|
||||||
// Remove the endpoint part and keep just the path for the request
|
// Remove the endpoint part and keep just the path for the request
|
||||||
format!("/auth/request_access_token?request_id={}", request_id)
|
format!("/auth/access_token?request_id={}", request_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ use tauri::AppHandle;
|
|||||||
use tauri_plugin_store::StoreExt;
|
use tauri_plugin_store::StoreExt;
|
||||||
|
|
||||||
const SETTINGS_ALLOW_SELF_SIGNATURE: &str = "settings_allow_self_signature";
|
const SETTINGS_ALLOW_SELF_SIGNATURE: &str = "settings_allow_self_signature";
|
||||||
|
const LOCAL_QUERY_SOURCE_WEIGHT: &str = "local_query_source_weight";
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn set_allow_self_signature(tauri_app_handle: AppHandle, value: bool) {
|
pub async fn set_allow_self_signature(tauri_app_handle: AppHandle, value: bool) {
|
||||||
@@ -70,3 +71,45 @@ pub fn _get_allow_self_signature(tauri_app_handle: AppHandle) -> bool {
|
|||||||
pub async fn get_allow_self_signature(tauri_app_handle: AppHandle) -> bool {
|
pub async fn get_allow_self_signature(tauri_app_handle: AppHandle) -> bool {
|
||||||
_get_allow_self_signature(tauri_app_handle)
|
_get_allow_self_signature(tauri_app_handle)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn set_local_query_source_weight(tauri_app_handle: AppHandle, value: f64) {
|
||||||
|
let store = tauri_app_handle
|
||||||
|
.store(COCO_TAURI_STORE)
|
||||||
|
.unwrap_or_else(|e| {
|
||||||
|
panic!(
|
||||||
|
"store [{}] not found/loaded, error [{}]",
|
||||||
|
COCO_TAURI_STORE, e
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
store.set(LOCAL_QUERY_SOURCE_WEIGHT, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_local_query_source_weight(tauri_app_handle: AppHandle) -> f64 {
|
||||||
|
// default to 1.0
|
||||||
|
const DEFAULT: f64 = 1.0;
|
||||||
|
|
||||||
|
let store = tauri_app_handle
|
||||||
|
.store(COCO_TAURI_STORE)
|
||||||
|
.unwrap_or_else(|e| {
|
||||||
|
panic!(
|
||||||
|
"store [{}] not found/loaded, error [{}]",
|
||||||
|
COCO_TAURI_STORE, e
|
||||||
|
)
|
||||||
|
});
|
||||||
|
if !store.has(LOCAL_QUERY_SOURCE_WEIGHT) {
|
||||||
|
store.set(LOCAL_QUERY_SOURCE_WEIGHT, DEFAULT);
|
||||||
|
}
|
||||||
|
|
||||||
|
match store
|
||||||
|
.get(LOCAL_QUERY_SOURCE_WEIGHT)
|
||||||
|
.expect("should be Some")
|
||||||
|
{
|
||||||
|
Json::Number(n) => n
|
||||||
|
.as_f64()
|
||||||
|
.unwrap_or_else(|| panic!("setting [{}] should be a f64", LOCAL_QUERY_SOURCE_WEIGHT)),
|
||||||
|
_ => unreachable!("{} should be stored as a number", LOCAL_QUERY_SOURCE_WEIGHT),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,17 +1,26 @@
|
|||||||
//! credits to: https://github.com/ayangweb/ayangweb-EcoPaste/blob/169323dbe6365ffe4abb64d867439ed2ea84c6d1/src-tauri/src/core/setup/mac.rs
|
//! credits to: https://github.com/ayangweb/ayangweb-EcoPaste/blob/169323dbe6365ffe4abb64d867439ed2ea84c6d1/src-tauri/src/core/setup/mac.rs
|
||||||
|
|
||||||
use tauri::{AppHandle, Emitter, EventTarget, WebviewWindow};
|
|
||||||
use tauri_nspanel::{WebviewWindowExt, cocoa::appkit::NSWindowCollectionBehavior, panel_delegate};
|
|
||||||
|
|
||||||
use crate::common::MAIN_WINDOW_LABEL;
|
use crate::common::MAIN_WINDOW_LABEL;
|
||||||
|
use tauri::{AppHandle, Emitter, EventTarget, Manager, WebviewWindow};
|
||||||
#[allow(non_upper_case_globals)]
|
use tauri_nspanel::{CollectionBehavior, PanelLevel, StyleMask, WebviewWindowExt, tauri_panel};
|
||||||
const NSWindowStyleMaskNonActivatingPanel: i32 = 1 << 7;
|
|
||||||
|
|
||||||
const WINDOW_FOCUS_EVENT: &str = "tauri://focus";
|
const WINDOW_FOCUS_EVENT: &str = "tauri://focus";
|
||||||
const WINDOW_BLUR_EVENT: &str = "tauri://blur";
|
const WINDOW_BLUR_EVENT: &str = "tauri://blur";
|
||||||
const WINDOW_MOVED_EVENT: &str = "tauri://move";
|
|
||||||
const WINDOW_RESIZED_EVENT: &str = "tauri://resize";
|
tauri_panel! {
|
||||||
|
panel!(NsPanel {
|
||||||
|
config: {
|
||||||
|
is_floating_panel: true,
|
||||||
|
can_become_key_window: true,
|
||||||
|
can_become_main_window: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
panel_event!(NsPanelEventHandler {
|
||||||
|
window_did_become_key(notification: &NSNotification) -> (),
|
||||||
|
window_did_resign_key(notification: &NSNotification) -> (),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pub fn platform(
|
pub fn platform(
|
||||||
_tauri_app_handle: &AppHandle,
|
_tauri_app_handle: &AppHandle,
|
||||||
@@ -20,62 +29,39 @@ pub fn platform(
|
|||||||
_check_window: WebviewWindow,
|
_check_window: WebviewWindow,
|
||||||
) {
|
) {
|
||||||
// Convert ns_window to ns_panel
|
// Convert ns_window to ns_panel
|
||||||
let panel = main_window.to_panel().unwrap();
|
let panel = main_window.to_panel::<NsPanel>().unwrap();
|
||||||
|
|
||||||
// Make the window above the dock
|
// set level
|
||||||
panel.set_level(20);
|
panel.set_level(PanelLevel::Utility.value());
|
||||||
|
|
||||||
// Do not steal focus from other windows
|
// Do not steal focus from other windows
|
||||||
panel.set_style_mask(NSWindowStyleMaskNonActivatingPanel);
|
panel.set_style_mask(StyleMask::empty().nonactivating_panel().into());
|
||||||
|
|
||||||
// Open the window in the active workspace and full screen
|
// Open the window in the active workspace and full screen
|
||||||
panel.set_collection_behaviour(
|
panel.set_collection_behavior(
|
||||||
NSWindowCollectionBehavior::NSWindowCollectionBehaviorMoveToActiveSpace
|
CollectionBehavior::new()
|
||||||
| NSWindowCollectionBehavior::NSWindowCollectionBehaviorStationary
|
.stationary()
|
||||||
| NSWindowCollectionBehavior::NSWindowCollectionBehaviorFullScreenAuxiliary,
|
.move_to_active_space()
|
||||||
|
.full_screen_auxiliary()
|
||||||
|
.into(),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Define the panel's delegate to listen to panel window events
|
let handler = NsPanelEventHandler::new();
|
||||||
let delegate = panel_delegate!(EcoPanelDelegate {
|
|
||||||
window_did_become_key,
|
|
||||||
window_did_resign_key,
|
|
||||||
window_did_resize,
|
|
||||||
window_did_move
|
|
||||||
});
|
|
||||||
|
|
||||||
// Set event listeners for the delegate
|
let window = main_window.clone();
|
||||||
delegate.set_listener(Box::new(move |delegate_name: String| {
|
handler.window_did_become_key(move |_| {
|
||||||
let target = EventTarget::labeled(MAIN_WINDOW_LABEL);
|
let target = EventTarget::labeled(MAIN_WINDOW_LABEL);
|
||||||
|
|
||||||
let window_move_event = || {
|
let _ = window.emit_to(target, WINDOW_FOCUS_EVENT, true);
|
||||||
if let Ok(position) = main_window.outer_position() {
|
});
|
||||||
let _ = main_window.emit_to(target.clone(), WINDOW_MOVED_EVENT, position);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
match delegate_name.as_str() {
|
let window = main_window.clone();
|
||||||
// Called when the window gets keyboard focus
|
handler.window_did_resign_key(move |_| {
|
||||||
"window_did_become_key" => {
|
let target = EventTarget::labeled(MAIN_WINDOW_LABEL);
|
||||||
let _ = main_window.emit_to(target, WINDOW_FOCUS_EVENT, true);
|
|
||||||
}
|
|
||||||
// Called when the window loses keyboard focus
|
|
||||||
"window_did_resign_key" => {
|
|
||||||
let _ = main_window.emit_to(target, WINDOW_BLUR_EVENT, true);
|
|
||||||
}
|
|
||||||
// Called when the window size changes
|
|
||||||
"window_did_resize" => {
|
|
||||||
window_move_event();
|
|
||||||
|
|
||||||
if let Ok(size) = main_window.inner_size() {
|
let _ = window.emit_to(target, WINDOW_BLUR_EVENT, true);
|
||||||
let _ = main_window.emit_to(target, WINDOW_RESIZED_EVENT, size);
|
});
|
||||||
}
|
|
||||||
}
|
|
||||||
// Called when the window position changes
|
|
||||||
"window_did_move" => window_move_event(),
|
|
||||||
_ => (),
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Set the delegate object for the window to handle window events
|
// Set the delegate object for the window to handle window events
|
||||||
panel.set_delegate(delegate);
|
panel.set_event_handler(Some(handler.as_ref()));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ use crate::GLOBAL_TAURI_APP_HANDLE;
|
|||||||
use crate::autostart;
|
use crate::autostart;
|
||||||
use crate::common::register::SearchSourceRegistry;
|
use crate::common::register::SearchSourceRegistry;
|
||||||
use crate::util::app_lang::update_app_lang;
|
use crate::util::app_lang::update_app_lang;
|
||||||
use std::sync::OnceLock;
|
use std::sync::atomic::AtomicBool;
|
||||||
|
use std::sync::atomic::Ordering;
|
||||||
use tauri::{AppHandle, Manager, WebviewWindow};
|
use tauri::{AppHandle, Manager, WebviewWindow};
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
@@ -41,9 +42,11 @@ pub fn default(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Use this variable to track if tauri command `backend_setup()` gets called
|
/// Indicates if the setup job is completed.
|
||||||
/// by the frontend.
|
static BACKEND_SETUP_COMPLETED: AtomicBool = AtomicBool::new(false);
|
||||||
pub(super) static BACKEND_SETUP_FUNC_INVOKED: OnceLock<()> = OnceLock::new();
|
/// The function `backup_setup()` may be called concurrently, use this lock to
|
||||||
|
/// synchronize that only 1 async task can do the actual setup job.
|
||||||
|
static MUTEX_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
|
||||||
|
|
||||||
/// This function includes the setup job that has to be coordinated with the
|
/// This function includes the setup job that has to be coordinated with the
|
||||||
/// frontend, or the App will panic due to races[1]. The way we coordinate is to
|
/// frontend, or the App will panic due to races[1]. The way we coordinate is to
|
||||||
@@ -60,9 +63,17 @@ pub(super) static BACKEND_SETUP_FUNC_INVOKED: OnceLock<()> = OnceLock::new();
|
|||||||
/// called. If the frontend code invokes `list_extensions()` before `init_extension()`
|
/// called. If the frontend code invokes `list_extensions()` before `init_extension()`
|
||||||
/// gets executed, we get a panic.
|
/// gets executed, we get a panic.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[function_name::named]
|
|
||||||
pub(crate) async fn backend_setup(tauri_app_handle: AppHandle, app_lang: String) {
|
pub(crate) async fn backend_setup(tauri_app_handle: AppHandle, app_lang: String) {
|
||||||
if BACKEND_SETUP_FUNC_INVOKED.get().is_some() {
|
if BACKEND_SETUP_COMPLETED.load(Ordering::Relaxed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Race to let one async task do the setup job
|
||||||
|
let _guard = MUTEX_LOCK.lock().await;
|
||||||
|
|
||||||
|
// Re-check in case the current async task is not the first one that acquires
|
||||||
|
// the lock
|
||||||
|
if BACKEND_SETUP_COMPLETED.load(Ordering::Relaxed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,7 +88,16 @@ pub(crate) async fn backend_setup(tauri_app_handle: AppHandle, app_lang: String)
|
|||||||
// This has to be called before initializing extensions as doing that
|
// This has to be called before initializing extensions as doing that
|
||||||
// requires access to the shortcut store, which will be set by this
|
// requires access to the shortcut store, which will be set by this
|
||||||
// function.
|
// function.
|
||||||
crate::shortcut::enable_shortcut(&tauri_app_handle);
|
//
|
||||||
|
//
|
||||||
|
// Windows requires that hotkey setup has to be done on the main thread, or
|
||||||
|
// we will get error "ERROR_WINDOW_OF_OTHER_THREAD 1408 (0x580)"
|
||||||
|
let tauri_app_handle_clone = tauri_app_handle.clone();
|
||||||
|
tauri_app_handle
|
||||||
|
.run_on_main_thread(move || {
|
||||||
|
crate::shortcut::enable_shortcut(&tauri_app_handle_clone);
|
||||||
|
})
|
||||||
|
.expect("failed to run this closure on the main thread");
|
||||||
|
|
||||||
crate::init(&tauri_app_handle).await;
|
crate::init(&tauri_app_handle).await;
|
||||||
|
|
||||||
@@ -93,7 +113,5 @@ pub(crate) async fn backend_setup(tauri_app_handle: AppHandle, app_lang: String)
|
|||||||
update_app_lang(app_lang).await;
|
update_app_lang(app_lang).await;
|
||||||
|
|
||||||
// Invoked, now update the state
|
// Invoked, now update the state
|
||||||
BACKEND_SETUP_FUNC_INVOKED
|
BACKEND_SETUP_COMPLETED.store(true, Ordering::Relaxed);
|
||||||
.set(())
|
|
||||||
.unwrap_or_else(|_| panic!("tauri command {}() gets called twice!", function_name!()));
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ pub(crate) enum FileType {
|
|||||||
Unknown,
|
Unknown,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_file_type(path: &str) -> FileType {
|
fn get_file_type(path: &str) -> FileType {
|
||||||
let path = camino::Utf8Path::new(path);
|
let path = camino::Utf8Path::new(path);
|
||||||
|
|
||||||
// stat() is more precise than file extension, use it if possible.
|
// stat() is more precise than file extension, use it if possible.
|
||||||
@@ -167,8 +167,13 @@ fn type_to_icon(ty: FileType) -> &'static str {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
/// Synchronous version of `get_file_icon()`.
|
||||||
pub(crate) async fn get_file_icon(path: String) -> &'static str {
|
pub(crate) fn sync_get_file_icon(path: &str) -> &'static str {
|
||||||
let ty = get_file_type(path.as_str()).await;
|
let ty = get_file_type(path);
|
||||||
type_to_icon(ty)
|
type_to_icon(ty)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub(crate) async fn get_file_icon(path: String) -> &'static str {
|
||||||
|
sync_get_file_icon(&path)
|
||||||
|
}
|
||||||
|
|||||||
189
src-tauri/src/util/logging.rs
Normal file
189
src-tauri/src/util/logging.rs
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
use tauri::plugin::TauriPlugin;
|
||||||
|
|
||||||
|
/// Return the log directory.
|
||||||
|
///
|
||||||
|
/// We use a custom log directory, which is similar to the one used by
|
||||||
|
/// Tauri, except that the "{bundleIdentifier}" will be "Coco AI" rather
|
||||||
|
/// than the real identifier.
|
||||||
|
///
|
||||||
|
/// We do this because our bundle ID ("rs.coco.app") ends with ".app", log directory
|
||||||
|
/// "/Users/xxx/Library/Logs/rs.coco.app" is mistakenly thought as an application
|
||||||
|
/// by Finder on macOS, making it inconvenient to open. We do not want to change the
|
||||||
|
/// bundle identifier. The data directory, which stores all the data, still
|
||||||
|
/// references it. So doing that will be a breaking change. Using a custom log
|
||||||
|
/// directory make more sense.
|
||||||
|
///
|
||||||
|
/// ### Platform-specific
|
||||||
|
///
|
||||||
|
/// |Platform | Value | Example |
|
||||||
|
/// | --------- | -------------------------------------------------------------------| --------------------------------------------|
|
||||||
|
/// | Linux | `$XDG_DATA_HOME/Coco AI/logs` or `$HOME/.local/share/Coco AI/logs` | `/home/alice/.local/share/Coco AI/logs` |
|
||||||
|
/// | macOS/iOS | `{homeDir}/Library/Logs/Coco AI` | `/Users/Alice/Library/Logs/Coco AI` |
|
||||||
|
/// | Windows | `{FOLDERID_LocalAppData}/Coco AI/logs` | `C:\Users\Alice\AppData\Local\Coco AI\logs` |
|
||||||
|
/// | Android | `{ConfigDir}/logs` | `/data/data/com.tauri.dev/files/logs` |
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn app_log_dir() -> PathBuf {
|
||||||
|
const IDENTIFIER: &str = "Coco AI";
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
let path = dirs::home_dir()
|
||||||
|
.expect("cannot find the home directory, Coco should never run in such a environment")
|
||||||
|
.join("Library/Logs")
|
||||||
|
.join(IDENTIFIER);
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
let path = dirs::data_local_dir()
|
||||||
|
.expect("app local dir is None, we should not encounter this")
|
||||||
|
.join(IDENTIFIER)
|
||||||
|
.join("logs");
|
||||||
|
|
||||||
|
path
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Log format:
|
||||||
|
///
|
||||||
|
/// ```text
|
||||||
|
/// [time] [log level] [file module:line] message
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// Example:
|
||||||
|
///
|
||||||
|
///
|
||||||
|
/// ```text
|
||||||
|
/// [05-11 17:00:00] [INF] [coco_lib:625] Coco-AI started
|
||||||
|
/// ```
|
||||||
|
pub(crate) fn set_up_tauri_logger() -> TauriPlugin<tauri::Wry> {
|
||||||
|
use log::Level;
|
||||||
|
use log::LevelFilter;
|
||||||
|
use tauri_plugin_log::Builder;
|
||||||
|
use tauri_plugin_log::Target;
|
||||||
|
use tauri_plugin_log::TargetKind;
|
||||||
|
|
||||||
|
/// Coco-AI app's default log level.
|
||||||
|
const DEFAULT_LOG_LEVEL: LevelFilter = LevelFilter::Info;
|
||||||
|
const LOG_LEVEL_ENV_VAR: &str = "COCO_LOG";
|
||||||
|
|
||||||
|
fn format_log_level(level: Level) -> &'static str {
|
||||||
|
match level {
|
||||||
|
Level::Trace => "TRC",
|
||||||
|
Level::Debug => "DBG",
|
||||||
|
Level::Info => "INF",
|
||||||
|
Level::Warn => "WAR",
|
||||||
|
Level::Error => "ERR",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_target_and_line(record: &log::Record) -> String {
|
||||||
|
let mut str = record.target().to_string();
|
||||||
|
if let Some(line) = record.line() {
|
||||||
|
str.push(':');
|
||||||
|
str.push_str(&line.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
str
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Allow us to configure dynamic log levels via environment variable `COCO_LOG`.
|
||||||
|
///
|
||||||
|
/// Generally, it mirrors the behavior of `env_logger`. Syntax: `COCO_LOG=[module][=][level][,...]`
|
||||||
|
///
|
||||||
|
/// * If this environment variable is not set, use the default log level.
|
||||||
|
/// * If it is set, respect it:
|
||||||
|
///
|
||||||
|
/// * `COCO_LOG=coco_lib` turns on all logging for the `coco_lib` module, which is
|
||||||
|
/// equivalent to `COCO_LOG=coco_lib=trace`
|
||||||
|
/// * `COCO_LOG=trace` turns on all logging for the application, regardless of its name
|
||||||
|
/// * `COCO_LOG=TRACE` turns on all logging for the application, regardless of its name (same as previous)
|
||||||
|
/// * `COCO_LOG=reqwest=debug` turns on debug logging for `reqwest`
|
||||||
|
/// * `COCO_LOG=trace,tauri=off` turns on all the logging except for the logs come from `tauri`
|
||||||
|
/// * `COCO_LOG=off` turns off all logging for the application
|
||||||
|
/// * `COCO_LOG=` Since the value is empty, turns off all logging for the application as well
|
||||||
|
fn dynamic_log_level(mut builder: Builder) -> Builder {
|
||||||
|
let Some(log_levels) = std::env::var_os(LOG_LEVEL_ENV_VAR) else {
|
||||||
|
return builder.level(DEFAULT_LOG_LEVEL);
|
||||||
|
};
|
||||||
|
|
||||||
|
builder = builder.level(LevelFilter::Off);
|
||||||
|
|
||||||
|
let log_levels = log_levels.into_string().unwrap_or_else(|e| {
|
||||||
|
panic!(
|
||||||
|
"The value '{}' set in environment variable '{}' is not UTF-8 encoded",
|
||||||
|
// Cannot use `.display()` here because that requires MSRV 1.87.0
|
||||||
|
e.to_string_lossy(),
|
||||||
|
LOG_LEVEL_ENV_VAR
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
// COCO_LOG=[module][=][level][,...]
|
||||||
|
let module_log_levels = log_levels.split(',');
|
||||||
|
for module_log_level in module_log_levels {
|
||||||
|
#[allow(clippy::collapsible_else_if)]
|
||||||
|
if let Some(char_index) = module_log_level.chars().position(|c| c == '=') {
|
||||||
|
let (module, equal_sign_and_level) = module_log_level.split_at(char_index);
|
||||||
|
// Remove the equal sign, we know it takes 1 byte
|
||||||
|
let level = &equal_sign_and_level[1..];
|
||||||
|
|
||||||
|
if let Ok(level) = level.parse::<LevelFilter>() {
|
||||||
|
// Here we have to call `.to_string()` because `Cow<'static, str>` requires `&'static str`
|
||||||
|
builder = builder.level_for(module.to_string(), level);
|
||||||
|
} else {
|
||||||
|
panic!(
|
||||||
|
"log level '{}' set in '{}={}' is invalid",
|
||||||
|
level, module, level
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if let Ok(level) = module_log_level.parse::<LevelFilter>() {
|
||||||
|
// This is a level
|
||||||
|
builder = builder.level(level);
|
||||||
|
} else {
|
||||||
|
// This is a module, enable all the logging
|
||||||
|
let module = module_log_level;
|
||||||
|
// Here we have to call `.to_string()` because `Cow<'static, str>` requires `&'static str`
|
||||||
|
builder = builder.level_for(module.to_string(), LevelFilter::Trace);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
builder
|
||||||
|
}
|
||||||
|
|
||||||
|
// When running the built binary, set `COCO_LOG` to `coco_lib=trace` to capture all logs
|
||||||
|
// that come from Coco in the log file, which helps with debugging.
|
||||||
|
if !tauri::is_dev() {
|
||||||
|
// We have absolutely no guarantee that we (We have control over the Rust
|
||||||
|
// code, but definitely no idea about the libc C code, all the shared objects
|
||||||
|
// that we will link) will not concurrently read/write `envp`, so just use unsafe.
|
||||||
|
unsafe {
|
||||||
|
std::env::set_var("COCO_LOG", "coco_lib=trace");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut builder = tauri_plugin_log::Builder::new();
|
||||||
|
builder = builder.format(|out, message, record| {
|
||||||
|
let now = chrono::Local::now().format("%m-%d %H:%M:%S");
|
||||||
|
let level = format_log_level(record.level());
|
||||||
|
let target_and_line = format_target_and_line(record);
|
||||||
|
out.finish(format_args!(
|
||||||
|
"[{}] [{}] [{}] {}",
|
||||||
|
now, level, target_and_line, message
|
||||||
|
));
|
||||||
|
});
|
||||||
|
builder = dynamic_log_level(builder);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Use our custom log directory
|
||||||
|
*/
|
||||||
|
// We have no public APIs to update targets in-place, so we need to remove
|
||||||
|
// them all, then bring back the correct ones.
|
||||||
|
builder = builder.clear_targets();
|
||||||
|
builder = builder.target(Target::new(TargetKind::Stdout));
|
||||||
|
builder = builder.target(Target::new(TargetKind::Folder {
|
||||||
|
path: app_log_dir(),
|
||||||
|
// Use the default value, which is "Coco-AI.log"
|
||||||
|
file_name: None,
|
||||||
|
}));
|
||||||
|
|
||||||
|
builder.build()
|
||||||
|
}
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
pub(crate) mod app_lang;
|
pub(crate) mod app_lang;
|
||||||
pub(crate) mod file;
|
pub(crate) mod file;
|
||||||
|
// We need this in main.rs, so it has to be pub
|
||||||
|
pub mod logging;
|
||||||
|
pub(crate) mod path;
|
||||||
pub(crate) mod platform;
|
pub(crate) mod platform;
|
||||||
|
pub(crate) mod prevent_default;
|
||||||
pub(crate) mod system_lang;
|
pub(crate) mod system_lang;
|
||||||
pub(crate) mod updater;
|
pub(crate) mod version;
|
||||||
|
|
||||||
use std::{path::Path, process::Command};
|
use std::{path::Path, process::Command};
|
||||||
use tauri::AppHandle;
|
use tauri::AppHandle;
|
||||||
@@ -12,7 +16,7 @@ use tauri_plugin_shell::ShellExt;
|
|||||||
const XDG_CURRENT_DESKTOP: &str = "XDG_CURRENT_DESKTOP";
|
const XDG_CURRENT_DESKTOP: &str = "XDG_CURRENT_DESKTOP";
|
||||||
|
|
||||||
#[derive(Debug, PartialEq)]
|
#[derive(Debug, PartialEq)]
|
||||||
enum LinuxDesktopEnvironment {
|
pub(crate) enum LinuxDesktopEnvironment {
|
||||||
Gnome,
|
Gnome,
|
||||||
Kde,
|
Kde,
|
||||||
Unsupported { xdg_current_desktop: String },
|
Unsupported { xdg_current_desktop: String },
|
||||||
@@ -64,7 +68,7 @@ impl LinuxDesktopEnvironment {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// None means that it is likely that we do not have a desktop environment.
|
/// None means that it is likely that we do not have a desktop environment.
|
||||||
fn get_linux_desktop_environment() -> Option<LinuxDesktopEnvironment> {
|
pub(crate) fn get_linux_desktop_environment() -> Option<LinuxDesktopEnvironment> {
|
||||||
let de_os_str = std::env::var_os(XDG_CURRENT_DESKTOP)?;
|
let de_os_str = std::env::var_os(XDG_CURRENT_DESKTOP)?;
|
||||||
let de_str = de_os_str.into_string().unwrap_or_else(|_os_string| {
|
let de_str = de_os_str.into_string().unwrap_or_else(|_os_string| {
|
||||||
panic!("${} should be UTF-8 encoded", XDG_CURRENT_DESKTOP);
|
panic!("${} should be UTF-8 encoded", XDG_CURRENT_DESKTOP);
|
||||||
|
|||||||
12
src-tauri/src/util/path.rs
Normal file
12
src-tauri/src/util/path.rs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
#[tauri::command]
|
||||||
|
pub(crate) fn path_absolute(path: &str) -> String {
|
||||||
|
// We do not use std::path::absolute() because it does not clean ".."
|
||||||
|
// https://doc.rust-lang.org/stable/std/path/fn.absolute.html#platform-specific-behavior
|
||||||
|
use path_clean::clean;
|
||||||
|
|
||||||
|
let clean_path = clean(path);
|
||||||
|
clean_path
|
||||||
|
.into_os_string()
|
||||||
|
.into_string()
|
||||||
|
.expect("path should be UTF-8 encoded")
|
||||||
|
}
|
||||||
@@ -54,7 +54,6 @@ impl Platform {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Returns a set that contains all the platforms.
|
/// Returns a set that contains all the platforms.
|
||||||
#[cfg(test)] // currently, only used in tests
|
|
||||||
pub(crate) fn all() -> std::collections::HashSet<Self> {
|
pub(crate) fn all() -> std::collections::HashSet<Self> {
|
||||||
Platform::VARIANTS.into_iter().copied().collect()
|
Platform::VARIANTS.into_iter().copied().collect()
|
||||||
}
|
}
|
||||||
|
|||||||
13
src-tauri/src/util/prevent_default.rs
Normal file
13
src-tauri/src/util/prevent_default.rs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
pub fn init() -> tauri::plugin::TauriPlugin<tauri::Wry> {
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
{
|
||||||
|
use tauri_plugin_prevent_default::Flags;
|
||||||
|
|
||||||
|
tauri_plugin_prevent_default::Builder::new()
|
||||||
|
.with_flags(Flags::all().difference(Flags::CONTEXT_MENU))
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(debug_assertions))]
|
||||||
|
tauri_plugin_prevent_default::init()
|
||||||
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
use sys_locale::get_locale;
|
|
||||||
|
|
||||||
/// Helper function to get the system language.
|
/// Helper function to get the system language.
|
||||||
///
|
///
|
||||||
/// We cannot return `enum Lang` here because Coco has limited language support
|
/// We cannot return `enum Lang` here because Coco has limited language support
|
||||||
/// but the OS supports many more languages.
|
/// but the OS supports many more languages.
|
||||||
|
#[cfg(feature = "use_pizza_engine")]
|
||||||
pub(crate) fn get_system_lang() -> String {
|
pub(crate) fn get_system_lang() -> String {
|
||||||
|
use sys_locale::get_locale;
|
||||||
|
|
||||||
// fall back to English (general) when we cannot get the locale
|
// fall back to English (general) when we cannot get the locale
|
||||||
//
|
//
|
||||||
// We replace '-' with '_' in applications-rs, to make the locales match,
|
// We replace '-' with '_' in applications-rs, to make the locales match,
|
||||||
|
|||||||
@@ -1,87 +0,0 @@
|
|||||||
use semver::Version;
|
|
||||||
use tauri_plugin_updater::RemoteRelease;
|
|
||||||
|
|
||||||
/// Helper function to extract the build number out of `version`.
|
|
||||||
///
|
|
||||||
/// If the version string is in the `x.y.z` format and does not include a build
|
|
||||||
/// number, we assume a build number of 0.
|
|
||||||
fn extract_build_number(version: &Version) -> u32 {
|
|
||||||
let pre = &version.pre;
|
|
||||||
|
|
||||||
if pre.is_empty() {
|
|
||||||
// A special value for the versions that do not have array
|
|
||||||
0
|
|
||||||
} else {
|
|
||||||
let pre_str = pre.as_str();
|
|
||||||
let build_number_str = {
|
|
||||||
match pre_str.strip_prefix("SNAPSHOT-") {
|
|
||||||
Some(str) => str,
|
|
||||||
None => pre_str,
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let build_number : u32 = build_number_str.parse().unwrap_or_else(|e| {
|
|
||||||
panic!(
|
|
||||||
"invalid build number, cannot parse [{}] to a valid build number, error [{}], version [{}]",
|
|
||||||
build_number_str, e, version
|
|
||||||
)
|
|
||||||
});
|
|
||||||
|
|
||||||
build_number
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// # Local version format
|
|
||||||
///
|
|
||||||
/// Packages built in our CI use the following format:
|
|
||||||
///
|
|
||||||
/// * `x.y.z-SNAPSHOT-<build number>`
|
|
||||||
/// * `x.y.z-<build number>`
|
|
||||||
///
|
|
||||||
/// If you build Coco from src, the version will be in format `x.y.z`
|
|
||||||
///
|
|
||||||
/// # Remote version format
|
|
||||||
///
|
|
||||||
/// `x.y.z-<build number>`
|
|
||||||
///
|
|
||||||
/// # How we compare versions
|
|
||||||
///
|
|
||||||
/// We compare versions based solely on the build number.
|
|
||||||
/// If the version string is in the `x.y.z` format and does not include a build number,
|
|
||||||
/// we assume a build number of 0. As a result, such versions are considered older
|
|
||||||
/// than any version with an explicit build number.
|
|
||||||
pub(crate) fn custom_version_comparator(local: Version, remote_release: RemoteRelease) -> bool {
|
|
||||||
let remote = remote_release.version;
|
|
||||||
|
|
||||||
let local_build_number = extract_build_number(&local);
|
|
||||||
let remote_build_number = extract_build_number(&remote);
|
|
||||||
|
|
||||||
let should_update = remote_build_number > local_build_number;
|
|
||||||
log::debug!(
|
|
||||||
"custom version comparator invoked, local version [{}], remote version [{}], should update [{}]",
|
|
||||||
local,
|
|
||||||
remote,
|
|
||||||
should_update
|
|
||||||
);
|
|
||||||
|
|
||||||
should_update
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_extract_build_number() {
|
|
||||||
// 0.6.0 => 0
|
|
||||||
let version = Version::parse("0.6.0").unwrap();
|
|
||||||
assert_eq!(extract_build_number(&version), 0);
|
|
||||||
|
|
||||||
// 0.6.0-2371 => 2371
|
|
||||||
let version = Version::parse("0.6.0-2371").unwrap();
|
|
||||||
assert_eq!(extract_build_number(&version), 2371);
|
|
||||||
|
|
||||||
// 0.6.0-SNAPSHOT-2371 => 2371
|
|
||||||
let version = Version::parse("0.6.0-SNAPSHOT-2371").unwrap();
|
|
||||||
assert_eq!(extract_build_number(&version), 2371);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
245
src-tauri/src/util/version.rs
Normal file
245
src-tauri/src/util/version.rs
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
use semver::{BuildMetadata, Prerelease, Version as SemVer};
|
||||||
|
use std::sync::LazyLock;
|
||||||
|
use tauri_plugin_updater::RemoteRelease;
|
||||||
|
|
||||||
|
const SNAPSHOT_DASH: &str = "SNAPSHOT-";
|
||||||
|
const SNAPSHOT_DASH_LEN: usize = SNAPSHOT_DASH.len();
|
||||||
|
// trim the last dash
|
||||||
|
const SNAPSHOT: &str = SNAPSHOT_DASH.split_at(SNAPSHOT_DASH_LEN - 1).0;
|
||||||
|
|
||||||
|
/// Coco app version, in SemVer format.
|
||||||
|
pub(crate) static COCO_VERSION: LazyLock<SemVer> = LazyLock::new(|| {
|
||||||
|
parse_coco_semver(env!("CARGO_PKG_VERSION")).expect("parsing should never fail, if version format changes, then parse_coco_semver() should be updated as well")
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Coco AI app adopt SemVer but the version string format does not adhere to
|
||||||
|
/// the SemVer specification, this function does the conversion. Returns `None`
|
||||||
|
/// if the input is not in the expected format so that the conversion cannot
|
||||||
|
/// complete.
|
||||||
|
///
|
||||||
|
/// # Example cases
|
||||||
|
///
|
||||||
|
/// * 0.8.0 => 0.8.0
|
||||||
|
///
|
||||||
|
/// You may see this when you develop Coco locally
|
||||||
|
///
|
||||||
|
/// * 0.8.0-<build num> => 0.8.0
|
||||||
|
///
|
||||||
|
/// This is the official release for 0.8.0
|
||||||
|
///
|
||||||
|
/// * 0.9.0-SNAPSHOT-<build num> => 0.9.0-SNAPSHOT.<build num>
|
||||||
|
///
|
||||||
|
/// A pre-release of 0.9.0
|
||||||
|
fn to_semver(version: &SemVer) -> Option<SemVer> {
|
||||||
|
let pre = &version.pre;
|
||||||
|
|
||||||
|
if pre.is_empty() {
|
||||||
|
return Some(SemVer::new(version.major, version.minor, version.patch));
|
||||||
|
}
|
||||||
|
let is_pre_release = pre.starts_with(SNAPSHOT_DASH);
|
||||||
|
|
||||||
|
let build_number_str = if is_pre_release {
|
||||||
|
&pre[SNAPSHOT_DASH_LEN..]
|
||||||
|
} else {
|
||||||
|
pre.as_str()
|
||||||
|
};
|
||||||
|
// Parse the build number to validate it, we do not need the actual number though.
|
||||||
|
build_number_str.parse::<usize>().ok()?;
|
||||||
|
|
||||||
|
// Return after checking the build number is valid
|
||||||
|
if !is_pre_release {
|
||||||
|
return Some(SemVer::new(version.major, version.minor, version.patch));
|
||||||
|
}
|
||||||
|
|
||||||
|
let pre = {
|
||||||
|
let pre_str = format!("{}.{}", SNAPSHOT, build_number_str);
|
||||||
|
Prerelease::new(&pre_str).unwrap_or_else(|e| panic!("invalid Prerelease: {}", e))
|
||||||
|
};
|
||||||
|
|
||||||
|
Some(SemVer {
|
||||||
|
major: version.major,
|
||||||
|
minor: version.minor,
|
||||||
|
patch: version.patch,
|
||||||
|
pre,
|
||||||
|
build: BuildMetadata::EMPTY,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse Coco version string to a `SemVer`. Returns `None` if it is not a valid
|
||||||
|
/// version string.
|
||||||
|
pub(crate) fn parse_coco_semver(version_str: &str) -> Option<SemVer> {
|
||||||
|
let not_semver = SemVer::parse(version_str).ok()?;
|
||||||
|
to_semver(¬_semver)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn custom_version_comparator(local: SemVer, remote_release: RemoteRelease) -> bool {
|
||||||
|
let remote = remote_release.version;
|
||||||
|
let local_semver = to_semver(&local);
|
||||||
|
let remote_semver = to_semver(&remote);
|
||||||
|
|
||||||
|
let should_update = remote_semver > local_semver;
|
||||||
|
|
||||||
|
log::debug!(
|
||||||
|
"custom version comparator invoked, local version [{}], remote version [{}], should update [{}]",
|
||||||
|
local,
|
||||||
|
remote,
|
||||||
|
should_update
|
||||||
|
);
|
||||||
|
|
||||||
|
should_update
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use tauri_plugin_updater::RemoteReleaseInner;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_try_into_semver_local_dev() {
|
||||||
|
// Case: 0.8.0 => 0.8.0
|
||||||
|
// Local development version without any pre-release or build metadata
|
||||||
|
let input = SemVer::parse("0.8.0").unwrap();
|
||||||
|
let result = to_semver(&input).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(result.major, 0);
|
||||||
|
assert_eq!(result.minor, 8);
|
||||||
|
assert_eq!(result.patch, 0);
|
||||||
|
assert_eq!(result.pre, Prerelease::EMPTY);
|
||||||
|
assert!(result.build.is_empty());
|
||||||
|
assert_eq!(result.to_string(), "0.8.0");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_try_into_semver_official_release() {
|
||||||
|
// Case: 0.8.0-<build num> => 0.8.0
|
||||||
|
// Official release with build number in pre-release field
|
||||||
|
let input = SemVer::parse("0.8.0-123").unwrap();
|
||||||
|
let result = to_semver(&input).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(result.major, 0);
|
||||||
|
assert_eq!(result.minor, 8);
|
||||||
|
assert_eq!(result.patch, 0);
|
||||||
|
assert_eq!(result.pre, Prerelease::EMPTY);
|
||||||
|
assert!(result.build.is_empty());
|
||||||
|
assert_eq!(result.to_string(), "0.8.0");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_try_into_semver_pre_release() {
|
||||||
|
// Case: 0.9.0-SNAPSHOT-<build num> => 0.9.0-SNAPSHOT.<build num>
|
||||||
|
// Pre-release version with SNAPSHOT prefix
|
||||||
|
let input = SemVer::parse("0.9.0-SNAPSHOT-456").unwrap();
|
||||||
|
let result = to_semver(&input).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(result.major, 0);
|
||||||
|
assert_eq!(result.minor, 9);
|
||||||
|
assert_eq!(result.patch, 0);
|
||||||
|
assert_eq!(result.pre.as_str(), "SNAPSHOT.456");
|
||||||
|
assert!(result.build.is_empty());
|
||||||
|
assert_eq!(result.to_string(), "0.9.0-SNAPSHOT.456");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_try_into_semver_official_release_different_version() {
|
||||||
|
// Test with different version numbers
|
||||||
|
let input = SemVer::parse("1.2.3-9999").unwrap();
|
||||||
|
let result = to_semver(&input).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(result.major, 1);
|
||||||
|
assert_eq!(result.minor, 2);
|
||||||
|
assert_eq!(result.patch, 3);
|
||||||
|
assert_eq!(result.pre, Prerelease::EMPTY);
|
||||||
|
assert!(result.build.is_empty());
|
||||||
|
assert_eq!(result.to_string(), "1.2.3");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_try_into_semver_snapshot_different_version() {
|
||||||
|
// Test SNAPSHOT with different version numbers
|
||||||
|
let input = SemVer::parse("2.0.0-SNAPSHOT-777").unwrap();
|
||||||
|
let result = to_semver(&input).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(result.major, 2);
|
||||||
|
assert_eq!(result.minor, 0);
|
||||||
|
assert_eq!(result.patch, 0);
|
||||||
|
assert_eq!(result.pre.as_str(), "SNAPSHOT.777");
|
||||||
|
assert!(result.build.is_empty());
|
||||||
|
assert_eq!(result.to_string(), "2.0.0-SNAPSHOT.777");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_try_into_semver_invalid_build_number() {
|
||||||
|
// Should panic when build number is not a valid number
|
||||||
|
let input = SemVer::parse("0.8.0-abc").unwrap();
|
||||||
|
assert!(to_semver(&input).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_try_into_semver_invalid_snapshot_build_number() {
|
||||||
|
// Should panic when SNAPSHOT build number is not a valid number
|
||||||
|
let input = SemVer::parse("0.9.0-SNAPSHOT-xyz").unwrap();
|
||||||
|
assert!(to_semver(&input).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_custom_version_comparator() {
|
||||||
|
fn new_local(str: &str) -> SemVer {
|
||||||
|
SemVer::parse(str).unwrap()
|
||||||
|
}
|
||||||
|
fn new_remote_release(str: &str) -> RemoteRelease {
|
||||||
|
let version = SemVer::parse(str).unwrap();
|
||||||
|
|
||||||
|
RemoteRelease {
|
||||||
|
version,
|
||||||
|
notes: None,
|
||||||
|
pub_date: None,
|
||||||
|
data: RemoteReleaseInner::Static {
|
||||||
|
platforms: HashMap::new(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
custom_version_comparator(new_local("0.8.0"), new_remote_release("0.8.0-2518")),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
custom_version_comparator(new_local("0.8.0-2518"), new_remote_release("0.8.0")),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
custom_version_comparator(new_local("0.9.0-SNAPSHOT-1"), new_remote_release("0.9.0")),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
custom_version_comparator(new_local("0.9.0-SNAPSHOT-1"), new_remote_release("0.8.1")),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
custom_version_comparator(new_local("0.9.0-SNAPSHOT-1"), new_remote_release("0.9.0-2")),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
custom_version_comparator(
|
||||||
|
new_local("0.9.0-SNAPSHOT-1"),
|
||||||
|
new_remote_release("0.9.0-SNAPSHOT-1")
|
||||||
|
),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
custom_version_comparator(
|
||||||
|
new_local("0.9.0-SNAPSHOT-11"),
|
||||||
|
new_remote_release("0.9.0-SNAPSHOT-9")
|
||||||
|
),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
custom_version_comparator(
|
||||||
|
new_local("0.9.0-SNAPSHOT-11"),
|
||||||
|
new_remote_release("0.9.0-SNAPSHOT-19")
|
||||||
|
),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -65,20 +65,25 @@
|
|||||||
"height": 260,
|
"height": 260,
|
||||||
"minHeight": 260,
|
"minHeight": 260,
|
||||||
"center": false,
|
"center": false,
|
||||||
|
"decorations": false,
|
||||||
"transparent": true,
|
"transparent": true,
|
||||||
"maximizable": false,
|
"maximizable": false,
|
||||||
"skipTaskbar": false,
|
"skipTaskbar": false,
|
||||||
"dragDropEnabled": false,
|
"dragDropEnabled": false,
|
||||||
"hiddenTitle": true,
|
"hiddenTitle": true,
|
||||||
"visible": false,
|
"visible": false,
|
||||||
|
"shadow": false,
|
||||||
"windowEffects": {
|
"windowEffects": {
|
||||||
"effects": ["sidebar"],
|
"effects": ["sidebar"],
|
||||||
"state": "active"
|
"state": "active",
|
||||||
|
"radius": 7
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": {
|
"security": {
|
||||||
"csp": null,
|
"csp": {
|
||||||
|
"default-src": "'self' asset: http: https: ipc: blob: data:"
|
||||||
|
},
|
||||||
"dangerousDisableAssetCspModification": true,
|
"dangerousDisableAssetCspModification": true,
|
||||||
"assetProtocol": {
|
"assetProtocol": {
|
||||||
"enable": true,
|
"enable": true,
|
||||||
@@ -141,4 +146,4 @@
|
|||||||
},
|
},
|
||||||
"os": {}
|
"os": {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
|
||||||
|
axios.defaults.withCredentials = true;
|
||||||
|
|
||||||
import { useAppStore } from "@/stores/appStore";
|
import { useAppStore } from "@/stores/appStore";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -59,8 +61,19 @@ export const handleApiError = (error: any) => {
|
|||||||
message = error.message;
|
message = error.message;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const url =
|
||||||
|
error?.config?.url ||
|
||||||
|
error?.response?.config?.url ||
|
||||||
|
error?.request?.config?.url;
|
||||||
|
|
||||||
|
const suppressProfileError =
|
||||||
|
typeof url === "string" && url.includes("/account/profile");
|
||||||
|
|
||||||
console.error(error);
|
console.error(error);
|
||||||
addError(message, "error");
|
if (!suppressProfileError) {
|
||||||
|
addError(message, "error");
|
||||||
|
}
|
||||||
|
|
||||||
return error;
|
return error;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -78,7 +91,7 @@ export const Get = <T>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
axios
|
axios
|
||||||
.get(baseURL + url, { params })
|
.get(baseURL + url, { params, withCredentials: true })
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
let res: FcResponse<T>;
|
let res: FcResponse<T>;
|
||||||
if (clearFn !== undefined) {
|
if (clearFn !== undefined) {
|
||||||
@@ -113,6 +126,7 @@ export const Post = <T>(
|
|||||||
.post(baseURL + url, data, {
|
.post(baseURL + url, data, {
|
||||||
params,
|
params,
|
||||||
headers,
|
headers,
|
||||||
|
withCredentials: true,
|
||||||
} as any)
|
} as any)
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
resolve([null, result.data as FcResponse<T>]);
|
resolve([null, result.data as FcResponse<T>]);
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export async function streamPost({
|
|||||||
...(headersStorage),
|
...(headersStorage),
|
||||||
...(headers || {}),
|
...(headers || {}),
|
||||||
},
|
},
|
||||||
|
credentials: "include",
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import {
|
|||||||
MultiSourceQueryResponse,
|
MultiSourceQueryResponse,
|
||||||
} from "@/types/commands";
|
} from "@/types/commands";
|
||||||
import { useAppStore } from "@/stores/appStore";
|
import { useAppStore } from "@/stores/appStore";
|
||||||
import { useAuthStore } from "@/stores/authStore";
|
|
||||||
import {
|
import {
|
||||||
getCurrentWindowService,
|
getCurrentWindowService,
|
||||||
handleLogout,
|
handleLogout,
|
||||||
@@ -39,16 +38,9 @@ async function invokeWithErrorHandler<T>(
|
|||||||
command: string,
|
command: string,
|
||||||
args?: Record<string, any>
|
args?: Record<string, any>
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const isCurrentLogin = useAuthStore.getState().isCurrentLogin;
|
|
||||||
|
|
||||||
const service = await getCurrentWindowService();
|
const service = await getCurrentWindowService();
|
||||||
|
|
||||||
// Not logged in
|
if (!WHITELIST_SERVERS.includes(command) && !service?.profile) {
|
||||||
// console.log("isCurrentLogin", command, isCurrentLogin);
|
|
||||||
if (
|
|
||||||
!WHITELIST_SERVERS.includes(command) &&
|
|
||||||
(!isCurrentLogin || !service?.profile)
|
|
||||||
) {
|
|
||||||
console.error("This command requires authentication");
|
console.error("This command requires authentication");
|
||||||
throw new Error("This command requires authentication");
|
throw new Error("This command requires authentication");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useConnectStore } from "@/stores/connectStore";
|
|||||||
import { SETTINGS_WINDOW_LABEL } from "@/constants";
|
import { SETTINGS_WINDOW_LABEL } from "@/constants";
|
||||||
import platformAdapter from "@/utils/platformAdapter";
|
import platformAdapter from "@/utils/platformAdapter";
|
||||||
import { useAuthStore } from "@/stores/authStore";
|
import { useAuthStore } from "@/stores/authStore";
|
||||||
|
import { useExtensionsStore } from "@/stores/extensionsStore";
|
||||||
|
|
||||||
export async function getCurrentWindowService() {
|
export async function getCurrentWindowService() {
|
||||||
const currentService = useConnectStore.getState().currentService;
|
const currentService = useConnectStore.getState().currentService;
|
||||||
@@ -13,23 +14,42 @@ export async function getCurrentWindowService() {
|
|||||||
: currentService;
|
: currentService;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setCurrentWindowService(
|
export async function setCurrentWindowService(service: any, isAll?: boolean) {
|
||||||
service: any,
|
|
||||||
isAll?: boolean
|
|
||||||
) {
|
|
||||||
const { setCurrentService, setCloudSelectService } =
|
const { setCurrentService, setCloudSelectService } =
|
||||||
useConnectStore.getState();
|
useConnectStore.getState();
|
||||||
// all refresh logout
|
// all refresh logout
|
||||||
if (isAll) {
|
if (isAll) {
|
||||||
setCloudSelectService(service);
|
setCloudSelectService(service);
|
||||||
setCurrentService(service);
|
return setCurrentService(service);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
// current refresh
|
// current refresh
|
||||||
const windowLabel = await platformAdapter.getCurrentWindowLabel();
|
const windowLabel = await platformAdapter.getCurrentWindowLabel();
|
||||||
return windowLabel === SETTINGS_WINDOW_LABEL
|
|
||||||
? setCloudSelectService(service)
|
if (windowLabel === SETTINGS_WINDOW_LABEL) {
|
||||||
: setCurrentService(service);
|
const { currentService } = useConnectStore.getState();
|
||||||
|
const {
|
||||||
|
aiOverviewServer,
|
||||||
|
setAiOverviewServer,
|
||||||
|
quickAiAccessServer,
|
||||||
|
setQuickAiAccessServer,
|
||||||
|
} = useExtensionsStore.getState();
|
||||||
|
|
||||||
|
if (currentService?.id === service.id) {
|
||||||
|
setCurrentService(service);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (aiOverviewServer?.id === service.id) {
|
||||||
|
setAiOverviewServer(service);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (quickAiAccessServer?.id === service.id) {
|
||||||
|
setQuickAiAccessServer(service);
|
||||||
|
}
|
||||||
|
|
||||||
|
return setCloudSelectService(service);
|
||||||
|
}
|
||||||
|
|
||||||
|
return setCurrentService(service);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function handleLogout(serverId?: string) {
|
export async function handleLogout(serverId?: string) {
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export const AssistantFetcher = ({
|
|||||||
query?: string;
|
query?: string;
|
||||||
}) => {
|
}) => {
|
||||||
try {
|
try {
|
||||||
if (unrequitable()) {
|
if (await unrequitable()) {
|
||||||
return {
|
return {
|
||||||
total: 0,
|
total: 0,
|
||||||
list: [],
|
list: [],
|
||||||
|
|||||||
@@ -30,19 +30,23 @@ export function AssistantList({ assistantIDs = [] }: AssistantListProps) {
|
|||||||
const setCurrentAssistant = useConnectStore((state) => {
|
const setCurrentAssistant = useConnectStore((state) => {
|
||||||
return state.setCurrentAssistant;
|
return state.setCurrentAssistant;
|
||||||
});
|
});
|
||||||
|
const assistantList = useConnectStore((state) => state.assistantList);
|
||||||
|
|
||||||
const aiAssistant = useShortcutsStore((state) => state.aiAssistant);
|
const aiAssistant = useShortcutsStore((state) => state.aiAssistant);
|
||||||
|
|
||||||
const [assistants, setAssistants] = useState<any[]>([]);
|
const [assistants, setAssistants] = useState<any[]>([]);
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
const popoverRef = useRef<HTMLDivElement>(null);
|
const popoverRef = useRef<HTMLDivElement>(null);
|
||||||
const popoverButtonRef = useRef<HTMLButtonElement>(null);
|
const popoverButtonRef = useRef<HTMLButtonElement>(null);
|
||||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [keyword, setKeyword] = useState("");
|
const [keyword, setKeyword] = useState("");
|
||||||
|
|
||||||
const debounceKeyword = useDebounce(keyword, { wait: 500 });
|
const debounceKeyword = useDebounce(keyword, { wait: 500 });
|
||||||
|
|
||||||
const askAiAssistantId = useSearchStore((state) => state.askAiAssistantId);
|
const askAiAssistantId = useSearchStore((state) => state.askAiAssistantId);
|
||||||
const setAskAiAssistantId = useSearchStore((state) => {
|
const setAskAiAssistantId = useSearchStore((state) => {
|
||||||
return state.setAskAiAssistantId;
|
return state.setAskAiAssistantId;
|
||||||
});
|
});
|
||||||
const assistantList = useConnectStore((state) => state.assistantList);
|
|
||||||
|
|
||||||
const { fetchAssistant } = AssistantFetcher({
|
const { fetchAssistant } = AssistantFetcher({
|
||||||
debounceKeyword,
|
debounceKeyword,
|
||||||
@@ -221,7 +225,7 @@ export function AssistantList({ assistantIDs = [] }: AssistantListProps) {
|
|||||||
autoFocus
|
autoFocus
|
||||||
value={keyword}
|
value={keyword}
|
||||||
placeholder={t("assistant.popover.search")}
|
placeholder={t("assistant.popover.search")}
|
||||||
className="w-full h-8 px-2 bg-transparent border rounded-md dark:border-white/10"
|
className="w-full h-8 px-2 bg-transparent border rounded-[6px] dark:border-white/10"
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
setKeyword(event.target.value);
|
setKeyword(event.target.value);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ interface ChatAIProps {
|
|||||||
startPage?: StartPage;
|
startPage?: StartPage;
|
||||||
formatUrl?: (data: any) => string;
|
formatUrl?: (data: any) => string;
|
||||||
instanceId?: string;
|
instanceId?: string;
|
||||||
|
getChatHistoryChatPage?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SendMessageParams {
|
export interface SendMessageParams {
|
||||||
@@ -52,6 +53,7 @@ export interface ChatAIRef {
|
|||||||
init: (params: SendMessageParams) => void;
|
init: (params: SendMessageParams) => void;
|
||||||
cancelChat: () => void;
|
cancelChat: () => void;
|
||||||
clearChat: () => void;
|
clearChat: () => void;
|
||||||
|
onSelectChat: (chat: Chat) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ChatAI = memo(
|
const ChatAI = memo(
|
||||||
@@ -73,6 +75,7 @@ const ChatAI = memo(
|
|||||||
startPage,
|
startPage,
|
||||||
formatUrl,
|
formatUrl,
|
||||||
instanceId,
|
instanceId,
|
||||||
|
getChatHistoryChatPage,
|
||||||
},
|
},
|
||||||
ref
|
ref
|
||||||
) => {
|
) => {
|
||||||
@@ -80,6 +83,7 @@ const ChatAI = memo(
|
|||||||
init: init,
|
init: init,
|
||||||
cancelChat: () => cancelChat(activeChat),
|
cancelChat: () => cancelChat(activeChat),
|
||||||
clearChat: clearChat,
|
clearChat: clearChat,
|
||||||
|
onSelectChat: onSelectChat,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const curChatEnd = useChatStore((state) => state.curChatEnd);
|
const curChatEnd = useChatStore((state) => state.curChatEnd);
|
||||||
@@ -116,6 +120,12 @@ const ChatAI = memo(
|
|||||||
activeChatProp && setActiveChat(activeChatProp);
|
activeChatProp && setActiveChat(activeChatProp);
|
||||||
}, [activeChatProp]);
|
}, [activeChatProp]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const { setHasActiveChat } = useChatStore.getState();
|
||||||
|
|
||||||
|
setHasActiveChat(Boolean(activeChat));
|
||||||
|
}, [activeChat]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isTauri) return;
|
if (!isTauri) return;
|
||||||
|
|
||||||
@@ -193,7 +203,8 @@ const ChatAI = memo(
|
|||||||
isDeepThinkActive,
|
isDeepThinkActive,
|
||||||
isMCPActive,
|
isMCPActive,
|
||||||
changeInput,
|
changeInput,
|
||||||
showChatHistory
|
showChatHistory,
|
||||||
|
getChatHistoryChatPage
|
||||||
);
|
);
|
||||||
|
|
||||||
const { dealMsg } = useMessageHandler(
|
const { dealMsg } = useMessageHandler(
|
||||||
@@ -233,7 +244,7 @@ const ChatAI = memo(
|
|||||||
async (params: SendMessageParams) => {
|
async (params: SendMessageParams) => {
|
||||||
try {
|
try {
|
||||||
//console.log("init", curChatEnd, activeChat?._id);
|
//console.log("init", curChatEnd, activeChat?._id);
|
||||||
if (!isCurrentLogin) {
|
if (isTauri && !isCurrentLogin) {
|
||||||
addError("Please login to continue chatting");
|
addError("Please login to continue chatting");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -377,7 +388,7 @@ const ChatAI = memo(
|
|||||||
<div
|
<div
|
||||||
data-tauri-drag-region
|
data-tauri-drag-region
|
||||||
data-chat-instance={instanceId}
|
data-chat-instance={instanceId}
|
||||||
className={`flex flex-col rounded-md h-full overflow-hidden relative`}
|
className={`flex flex-col rounded-[6px] h-full overflow-hidden relative`}
|
||||||
>
|
>
|
||||||
<ChatHeader
|
<ChatHeader
|
||||||
clearChat={clearChat}
|
clearChat={clearChat}
|
||||||
@@ -390,7 +401,7 @@ const ChatAI = memo(
|
|||||||
assistantIDs={assistantIDs}
|
assistantIDs={assistantIDs}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{isCurrentLogin || !isTauri ? (
|
{!isTauri || (isTauri && isCurrentLogin) ? (
|
||||||
<>
|
<>
|
||||||
<ChatContent
|
<ChatContent
|
||||||
activeChat={activeChat}
|
activeChat={activeChat}
|
||||||
|
|||||||
@@ -5,12 +5,14 @@ import { ChatMessage } from "@/components/ChatMessage";
|
|||||||
import { Greetings } from "./Greetings";
|
import { Greetings } from "./Greetings";
|
||||||
import AttachmentList from "@/components/Assistant/AttachmentList";
|
import AttachmentList from "@/components/Assistant/AttachmentList";
|
||||||
import { useChatScroll } from "@/hooks/useChatScroll";
|
import { useChatScroll } from "@/hooks/useChatScroll";
|
||||||
|
|
||||||
import type { Chat, IChunkData } from "@/types/chat";
|
import type { Chat, IChunkData } from "@/types/chat";
|
||||||
import { useConnectStore } from "@/stores/connectStore";
|
import { useConnectStore } from "@/stores/connectStore";
|
||||||
// import SessionFile from "./SessionFile";
|
// import SessionFile from "./SessionFile";
|
||||||
import ScrollToBottom from "@/components/Common/ScrollToBottom";
|
import ScrollToBottom from "@/components/Common/ScrollToBottom";
|
||||||
import { useChatStore } from "@/stores/chatStore";
|
import { useChatStore } from "@/stores/chatStore";
|
||||||
|
import { useWebConfigStore } from "@/stores/webConfigStore";
|
||||||
|
import { useAppStore } from "@/stores/appStore";
|
||||||
|
import { NoResults } from "../Common/UI/NoResults";
|
||||||
|
|
||||||
interface ChatContentProps {
|
interface ChatContentProps {
|
||||||
activeChat?: Chat;
|
activeChat?: Chat;
|
||||||
@@ -45,20 +47,23 @@ export const ChatContent = ({
|
|||||||
handleSendMessage,
|
handleSendMessage,
|
||||||
formatUrl,
|
formatUrl,
|
||||||
}: ChatContentProps) => {
|
}: ChatContentProps) => {
|
||||||
const { currentSessionId, setCurrentSessionId } = useConnectStore();
|
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const { uploadAttachments } = useChatStore();
|
const currentSessionId = useConnectStore((state) => state.currentSessionId);
|
||||||
|
const setCurrentSessionId = useConnectStore(
|
||||||
|
(state) => state.setCurrentSessionId
|
||||||
|
);
|
||||||
|
const visibleStartPage = useConnectStore((state) => state.visibleStartPage);
|
||||||
|
|
||||||
|
const uploadAttachments = useChatStore((state) => state.uploadAttachments);
|
||||||
|
const curChatEnd = useChatStore((state) => state.curChatEnd);
|
||||||
|
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const { scrollToBottom } = useChatScroll(messagesEndRef);
|
const { scrollToBottom } = useChatScroll(messagesEndRef);
|
||||||
|
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const [isAtBottom, setIsAtBottom] = useState(true);
|
const [isAtBottom, setIsAtBottom] = useState(true);
|
||||||
const visibleStartPage = useConnectStore((state) => state.visibleStartPage);
|
|
||||||
|
|
||||||
const curChatEnd = useChatStore((state) => state.curChatEnd);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsAtBottom(true);
|
setIsAtBottom(true);
|
||||||
@@ -95,87 +100,100 @@ export const ChatContent = ({
|
|||||||
setIsAtBottom(isAtBottom);
|
setIsAtBottom(isAtBottom);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const { isTauri } = useAppStore();
|
||||||
|
const { disabled } = useWebConfigStore();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 overflow-hidden flex flex-col justify-between relative user-select-text">
|
<div className="flex-1 overflow-hidden flex flex-col justify-between relative user-select-text">
|
||||||
<div
|
{!isTauri && disabled ? (
|
||||||
ref={scrollRef}
|
<NoResults />
|
||||||
className="flex-1 w-full overflow-x-hidden overflow-y-auto border-t border-[rgba(0,0,0,0.1)] dark:border-[rgba(255,255,255,0.15)] custom-scrollbar relative"
|
) : (
|
||||||
onScroll={handleScroll}
|
<>
|
||||||
>
|
<div
|
||||||
{(!activeChat || activeChat?.messages?.length === 0) &&
|
ref={scrollRef}
|
||||||
!visibleStartPage && <Greetings />}
|
className="flex-1 w-full overflow-x-hidden overflow-y-auto border-t border-[rgba(0,0,0,0.1)] dark:border-[rgba(255,255,255,0.15)] custom-scrollbar relative"
|
||||||
|
onScroll={handleScroll}
|
||||||
|
>
|
||||||
|
{(!activeChat || activeChat?.messages?.length === 0) &&
|
||||||
|
!visibleStartPage && <Greetings />}
|
||||||
|
|
||||||
{activeChat?.messages?.map((message, index) => (
|
{activeChat?.messages?.map((message, index) => (
|
||||||
<ChatMessage
|
<ChatMessage
|
||||||
key={message._id + index}
|
key={message._id + index}
|
||||||
message={message}
|
message={message}
|
||||||
isTyping={false}
|
isTyping={false}
|
||||||
onResend={handleSendMessage}
|
onResend={handleSendMessage}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{(!curChatEnd ||
|
{(!curChatEnd ||
|
||||||
query_intent ||
|
query_intent ||
|
||||||
tools ||
|
tools ||
|
||||||
fetch_source ||
|
fetch_source ||
|
||||||
pick_source ||
|
pick_source ||
|
||||||
deep_read ||
|
deep_read ||
|
||||||
think ||
|
think ||
|
||||||
response) &&
|
response) &&
|
||||||
activeChat?._source?.id ? (
|
activeChat?._source?.id ? (
|
||||||
<ChatMessage
|
<ChatMessage
|
||||||
key={"current"}
|
key={"current"}
|
||||||
message={{
|
message={{
|
||||||
_id: "current",
|
_id: "current",
|
||||||
_source: {
|
_source: {
|
||||||
type: "assistant",
|
type: "assistant",
|
||||||
assistant_id:
|
assistant_id:
|
||||||
allMessages[allMessages.length - 1]?._source?.assistant_id,
|
allMessages[allMessages.length - 1]?._source
|
||||||
message: "",
|
?.assistant_id,
|
||||||
question: Question,
|
message: "",
|
||||||
},
|
question: Question,
|
||||||
}}
|
},
|
||||||
onResend={handleSendMessage}
|
}}
|
||||||
isTyping={!curChatEnd}
|
onResend={handleSendMessage}
|
||||||
query_intent={query_intent}
|
isTyping={!curChatEnd}
|
||||||
tools={tools}
|
query_intent={query_intent}
|
||||||
fetch_source={fetch_source}
|
tools={tools}
|
||||||
pick_source={pick_source}
|
fetch_source={fetch_source}
|
||||||
deep_read={deep_read}
|
pick_source={pick_source}
|
||||||
think={think}
|
deep_read={deep_read}
|
||||||
response={response}
|
think={think}
|
||||||
loadingStep={loadingStep}
|
response={response}
|
||||||
formatUrl={formatUrl}
|
loadingStep={loadingStep}
|
||||||
/>
|
formatUrl={formatUrl}
|
||||||
) : null}
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{timedoutShow ? (
|
{timedoutShow ? (
|
||||||
<ChatMessage
|
<ChatMessage
|
||||||
key={"timedout"}
|
key={"timedout"}
|
||||||
message={{
|
message={{
|
||||||
_id: "timedout",
|
_id: "timedout",
|
||||||
_source: {
|
_source: {
|
||||||
type: "assistant",
|
type: "assistant",
|
||||||
message: t("assistant.chat.timedout"),
|
message: t("assistant.chat.timedout"),
|
||||||
question: Question,
|
question: Question,
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
onResend={handleSendMessage}
|
onResend={handleSendMessage}
|
||||||
isTyping={false}
|
isTyping={false}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<div ref={messagesEndRef} />
|
<div ref={messagesEndRef} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{uploadAttachments.length > 0 && (
|
{uploadAttachments.length > 0 && (
|
||||||
<div key={currentSessionId} className="max-h-[120px] overflow-auto p-2">
|
<div
|
||||||
<AttachmentList />
|
key={currentSessionId}
|
||||||
</div>
|
className="max-h-[120px] overflow-auto p-2"
|
||||||
|
>
|
||||||
|
<AttachmentList />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* {currentSessionId && <SessionFile sessionId={currentSessionId} />} */}
|
||||||
|
|
||||||
|
<ScrollToBottom scrollRef={scrollRef} isAtBottom={isAtBottom} />
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* {currentSessionId && <SessionFile sessionId={currentSessionId} />} */}
|
|
||||||
|
|
||||||
<ScrollToBottom scrollRef={scrollRef} isAtBottom={isAtBottom} />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,18 +1,16 @@
|
|||||||
import { MessageSquarePlus } from "lucide-react";
|
import { MessageSquarePlus } from "lucide-react";
|
||||||
import clsx from "clsx";
|
|
||||||
|
|
||||||
import HistoryIcon from "@/icons/History";
|
import HistoryIcon from "@/icons/History";
|
||||||
import PinOffIcon from "@/icons/PinOff";
|
|
||||||
import PinIcon from "@/icons/Pin";
|
|
||||||
import WindowsFullIcon from "@/icons/WindowsFull";
|
import WindowsFullIcon from "@/icons/WindowsFull";
|
||||||
import { useAppStore } from "@/stores/appStore";
|
import { useAppStore } from "@/stores/appStore";
|
||||||
import type { Chat } from "@/types/chat";
|
import type { Chat } from "@/types/chat";
|
||||||
import platformAdapter from "@/utils/platformAdapter";
|
|
||||||
import VisibleKey from "../Common/VisibleKey";
|
import VisibleKey from "../Common/VisibleKey";
|
||||||
import { useShortcutsStore } from "@/stores/shortcutsStore";
|
import { useShortcutsStore } from "@/stores/shortcutsStore";
|
||||||
import { HISTORY_PANEL_ID } from "@/constants";
|
import { HISTORY_PANEL_ID } from "@/constants";
|
||||||
import { AssistantList } from "./AssistantList";
|
import { AssistantList } from "./AssistantList";
|
||||||
import { ServerList } from "./ServerList";
|
import { ServerList } from "./ServerList";
|
||||||
|
import TogglePin from "../Common/TogglePin";
|
||||||
|
import WebLogin from "../WebLogin";
|
||||||
|
|
||||||
interface ChatHeaderProps {
|
interface ChatHeaderProps {
|
||||||
clearChat: () => void;
|
clearChat: () => void;
|
||||||
@@ -35,21 +33,9 @@ export function ChatHeader({
|
|||||||
showChatHistory = true,
|
showChatHistory = true,
|
||||||
assistantIDs,
|
assistantIDs,
|
||||||
}: ChatHeaderProps) {
|
}: ChatHeaderProps) {
|
||||||
const { isPinned, setIsPinned, isTauri } = useAppStore();
|
const { isTauri } = useAppStore();
|
||||||
|
|
||||||
const { historicalRecords, newSession, fixedWindow, external } =
|
const { historicalRecords, newSession, external } = useShortcutsStore();
|
||||||
useShortcutsStore();
|
|
||||||
|
|
||||||
const togglePin = async () => {
|
|
||||||
try {
|
|
||||||
const newPinned = !isPinned;
|
|
||||||
await platformAdapter.setAlwaysOnTop(newPinned);
|
|
||||||
setIsPinned(newPinned);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to toggle window pin state:", err);
|
|
||||||
setIsPinned(isPinned);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header
|
<header
|
||||||
@@ -78,7 +64,7 @@ export function ChatHeader({
|
|||||||
|
|
||||||
<AssistantList assistantIDs={assistantIDs} />
|
<AssistantList assistantIDs={assistantIDs} />
|
||||||
|
|
||||||
{showChatHistory ? (
|
{showChatHistory && (
|
||||||
<button
|
<button
|
||||||
onClick={clearChat}
|
onClick={clearChat}
|
||||||
className="p-2 py-1 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800"
|
className="p-2 py-1 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||||
@@ -91,7 +77,7 @@ export function ChatHeader({
|
|||||||
<MessageSquarePlus className="h-4 w-4 relative top-0.5" />
|
<MessageSquarePlus className="h-4 w-4 relative top-0.5" />
|
||||||
</VisibleKey>
|
</VisibleKey>
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2 className="max-w-[calc(100%-200px)] text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
|
<h2 className="max-w-[calc(100%-200px)] text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
|
||||||
@@ -102,16 +88,7 @@ export function ChatHeader({
|
|||||||
|
|
||||||
{isTauri ? (
|
{isTauri ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<TogglePin className="inline-flex" />
|
||||||
onClick={togglePin}
|
|
||||||
className={clsx("inline-flex", {
|
|
||||||
"text-blue-500": isPinned,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<VisibleKey shortcut={fixedWindow} onKeyPress={togglePin}>
|
|
||||||
{isPinned ? <PinIcon /> : <PinOffIcon />}
|
|
||||||
</VisibleKey>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<ServerList clearChat={clearChat} />
|
<ServerList clearChat={clearChat} />
|
||||||
|
|
||||||
@@ -124,7 +101,7 @@ export function ChatHeader({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div />
|
<WebLogin panelClassName="top-8 right-0" />
|
||||||
)}
|
)}
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ const ConnectPrompt = () => {
|
|||||||
<p className="mb-4 w-[388px]">{t("assistant.chat.connect_tip")}</p>
|
<p className="mb-4 w-[388px]">{t("assistant.chat.connect_tip")}</p>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className="flex items-center gap-2 px-6 py-2 rounded-md text-[#0072ff] transition-colors"
|
className="flex items-center gap-2 px-6 py-2 rounded-[6px] text-[#0072ff] transition-colors"
|
||||||
onClick={handleConnect}
|
onClick={handleConnect}
|
||||||
>
|
>
|
||||||
<span>{t("assistant.chat.connect")}</span>
|
<span>{t("assistant.chat.connect")}</span>
|
||||||
|
|||||||
@@ -18,7 +18,10 @@ import StatusIndicator from "@/components/Cloud/StatusIndicator";
|
|||||||
import { useAuthStore } from "@/stores/authStore";
|
import { useAuthStore } from "@/stores/authStore";
|
||||||
import { useSearchStore } from "@/stores/searchStore";
|
import { useSearchStore } from "@/stores/searchStore";
|
||||||
import { useServers } from "@/hooks/useServers";
|
import { useServers } from "@/hooks/useServers";
|
||||||
import { getCurrentWindowService, setCurrentWindowService } from "@/commands/windowService";
|
import {
|
||||||
|
getCurrentWindowService,
|
||||||
|
setCurrentWindowService,
|
||||||
|
} from "@/commands/windowService";
|
||||||
|
|
||||||
interface ServerListProps {
|
interface ServerListProps {
|
||||||
clearChat: () => void;
|
clearChat: () => void;
|
||||||
@@ -33,10 +36,9 @@ export function ServerList({ clearChat }: ServerListProps) {
|
|||||||
);
|
);
|
||||||
const setEndpoint = useAppStore((state) => state.setEndpoint);
|
const setEndpoint = useAppStore((state) => state.setEndpoint);
|
||||||
const isTauri = useAppStore((state) => state.isTauri);
|
const isTauri = useAppStore((state) => state.isTauri);
|
||||||
|
|
||||||
const currentService = useConnectStore((state) => state.currentService);
|
const currentService = useConnectStore((state) => state.currentService);
|
||||||
const cloudSelectService = useConnectStore((state) => {
|
const serverList = useConnectStore((state) => state.serverList);
|
||||||
return state.cloudSelectService;
|
|
||||||
});
|
|
||||||
|
|
||||||
const { setMessages } = useChatStore();
|
const { setMessages } = useChatStore();
|
||||||
|
|
||||||
@@ -55,7 +57,6 @@ export function ServerList({ clearChat }: ServerListProps) {
|
|||||||
const serverListButtonRef = useRef<HTMLButtonElement>(null);
|
const serverListButtonRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
const { refreshServerList } = useServers();
|
const { refreshServerList } = useServers();
|
||||||
const serverList = useConnectStore((state) => state.serverList);
|
|
||||||
|
|
||||||
const switchServer = async (server: IServer) => {
|
const switchServer = async (server: IServer) => {
|
||||||
if (!server) return;
|
if (!server) return;
|
||||||
@@ -95,8 +96,10 @@ export function ServerList({ clearChat }: ServerListProps) {
|
|||||||
} else {
|
} else {
|
||||||
switchServer(enabledServers[enabledServers.length - 1]);
|
switchServer(enabledServers[enabledServers.length - 1]);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
setCurrentWindowService({});
|
||||||
}
|
}
|
||||||
}, [currentService?.id, cloudSelectService?.id, serverList]);
|
}, [serverList]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!askAiServerId || serverList.length === 0) return;
|
if (!askAiServerId || serverList.length === 0) return;
|
||||||
@@ -194,7 +197,7 @@ export function ServerList({ clearChat }: ServerListProps) {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={openSettings}
|
onClick={openSettings}
|
||||||
className="p-1 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-500 dark:text-gray-400"
|
className="p-1 rounded-[6px] hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-500 dark:text-gray-400"
|
||||||
>
|
>
|
||||||
<VisibleKey shortcut=",">
|
<VisibleKey shortcut=",">
|
||||||
<Settings className="h-4 w-4 text-[#0287FF]" />
|
<Settings className="h-4 w-4 text-[#0287FF]" />
|
||||||
@@ -202,7 +205,7 @@ export function ServerList({ clearChat }: ServerListProps) {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleRefresh}
|
onClick={handleRefresh}
|
||||||
className="p-1 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-500 dark:text-gray-400"
|
className="p-1 rounded-[6px] hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-500 dark:text-gray-400"
|
||||||
disabled={isRefreshing}
|
disabled={isRefreshing}
|
||||||
>
|
>
|
||||||
<VisibleKey shortcut="R" onKeyPress={handleRefresh}>
|
<VisibleKey shortcut="R" onKeyPress={handleRefresh}>
|
||||||
@@ -229,11 +232,11 @@ export function ServerList({ clearChat }: ServerListProps) {
|
|||||||
: "hover:bg-gray-50 dark:hover:bg-gray-800/50"
|
: "hover:bg-gray-50 dark:hover:bg-gray-800/50"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 overflow-hidden min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<img
|
<img
|
||||||
src={server?.provider?.icon || logoImg}
|
src={server?.provider?.icon || logoImg}
|
||||||
alt={server.name}
|
alt={server.name}
|
||||||
className="w-6 h-6 rounded-full bg-gray-100 dark:bg-gray-800"
|
className="w-6 h-6 rounded-full bg-gray-100 dark:bg-gray-800 dark:drop-shadow-[0_0_6px_rgb(255,255,255)]"
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
const target = e.target as HTMLImageElement;
|
const target = e.target as HTMLImageElement;
|
||||||
target.src = logoImg;
|
target.src = logoImg;
|
||||||
|
|||||||
@@ -37,8 +37,6 @@ const SessionFile = (props: SessionFileProps) => {
|
|||||||
|
|
||||||
const getUploadedFiles = async () => {
|
const getUploadedFiles = async () => {
|
||||||
if (isTauri) {
|
if (isTauri) {
|
||||||
console.log("sessionId", sessionId);
|
|
||||||
|
|
||||||
const response: any = await platformAdapter.commands(
|
const response: any = await platformAdapter.commands(
|
||||||
"get_attachment_by_ids",
|
"get_attachment_by_ids",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ const Splash = ({ assistantIDs = [], startPage }: SplashProps) => {
|
|||||||
|
|
||||||
let response: any;
|
let response: any;
|
||||||
if (isTauri) {
|
if (isTauri) {
|
||||||
if (unrequitable()) {
|
if (await unrequitable()) {
|
||||||
return setVisibleStartPage(false);
|
return setVisibleStartPage(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export const MessageActions = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSpeak = async () => {
|
const handleSpeak = async () => {
|
||||||
if (isDefaultServer()) {
|
if (await isDefaultServer()) {
|
||||||
return setSynthesizeItem({ id, content });
|
return setSynthesizeItem({ id, content });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { useEffect, useRef, useState, useCallback } from "react";
|
import { useEffect, useRef, useState, useCallback } from "react";
|
||||||
|
import { isEqual } from "lodash-es";
|
||||||
|
import { usePrevious } from "ahooks";
|
||||||
|
|
||||||
import { DataSourcesList } from "./DataSourcesList";
|
import { DataSourcesList } from "./DataSourcesList";
|
||||||
import { Sidebar } from "./Sidebar";
|
import { Sidebar } from "./Sidebar";
|
||||||
@@ -7,9 +9,9 @@ import { useAppStore } from "@/stores/appStore";
|
|||||||
import { useConnectStore } from "@/stores/connectStore";
|
import { useConnectStore } from "@/stores/connectStore";
|
||||||
import ServiceInfo from "./ServiceInfo";
|
import ServiceInfo from "./ServiceInfo";
|
||||||
import ServiceAuth from "./ServiceAuth";
|
import ServiceAuth from "./ServiceAuth";
|
||||||
import platformAdapter from "@/utils/platformAdapter";
|
|
||||||
import type { Server } from "@/types/server";
|
import type { Server } from "@/types/server";
|
||||||
import { useServers } from "@/hooks/useServers";
|
import { useServers } from "@/hooks/useServers";
|
||||||
|
import platformAdapter from "@/utils/platformAdapter";
|
||||||
|
|
||||||
export default function Cloud() {
|
export default function Cloud() {
|
||||||
const SidebarRef = useRef<{ refreshData: () => void }>(null);
|
const SidebarRef = useRef<{ refreshData: () => void }>(null);
|
||||||
@@ -24,6 +26,7 @@ export default function Cloud() {
|
|||||||
serverList,
|
serverList,
|
||||||
setServerList,
|
setServerList,
|
||||||
} = useConnectStore();
|
} = useConnectStore();
|
||||||
|
const prevServerList = usePrevious(serverList);
|
||||||
|
|
||||||
const [refreshLoading, setRefreshLoading] = useState(false);
|
const [refreshLoading, setRefreshLoading] = useState(false);
|
||||||
|
|
||||||
@@ -31,6 +34,8 @@ export default function Cloud() {
|
|||||||
|
|
||||||
// fetch the servers
|
// fetch the servers
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (isEqual(prevServerList, serverList)) return;
|
||||||
|
|
||||||
fetchServers();
|
fetchServers();
|
||||||
}, [serverList]);
|
}, [serverList]);
|
||||||
|
|
||||||
@@ -40,32 +45,37 @@ export default function Cloud() {
|
|||||||
}, [cloudSelectService?.id]);
|
}, [cloudSelectService?.id]);
|
||||||
|
|
||||||
const fetchServers = useCallback(async () => {
|
const fetchServers = useCallback(async () => {
|
||||||
let res = serverList;
|
let { serverList } = useConnectStore.getState();
|
||||||
|
|
||||||
if (errors.length > 0) {
|
if (errors.length > 0) {
|
||||||
res = res.map((item: Server) => {
|
serverList = serverList.map((item: Server) => {
|
||||||
if (item.id === cloudSelectService?.id) {
|
if (item.id === cloudSelectService?.id) {
|
||||||
item.health = {
|
return {
|
||||||
services: item.health?.services || {},
|
...item,
|
||||||
status: item.health?.status || "red",
|
health: {
|
||||||
|
services: item.health?.services || {},
|
||||||
|
status: item.health?.status || "red",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return item;
|
return item;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
setServerList(res);
|
|
||||||
|
|
||||||
if (res.length > 0) {
|
setServerList(serverList);
|
||||||
const matched = res.find((server: any) => {
|
|
||||||
|
if (serverList.length > 0) {
|
||||||
|
const matched = serverList.find((server: any) => {
|
||||||
return server.id === cloudSelectService?.id;
|
return server.id === cloudSelectService?.id;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (matched) {
|
if (matched) {
|
||||||
setCloudSelectService(matched);
|
setCloudSelectService(matched);
|
||||||
} else {
|
} else {
|
||||||
setCloudSelectService(res[res.length - 1]);
|
setCloudSelectService(serverList[serverList.length - 1]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [serverList, errors, cloudSelectService]);
|
}, [errors, cloudSelectService]);
|
||||||
|
|
||||||
const refreshClick = useCallback(
|
const refreshClick = useCallback(
|
||||||
async (id: string, callback?: () => void) => {
|
async (id: string, callback?: () => void) => {
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export function DataSourceItem({ name, icon, connector }: DataSourceItemProps) {
|
|||||||
{icon?.startsWith("font_") ? (
|
{icon?.startsWith("font_") ? (
|
||||||
<FontIcon name={icon} className="size-6" />
|
<FontIcon name={icon} className="size-6" />
|
||||||
) : (
|
) : (
|
||||||
<img src={getTypeIcon()} alt={name} className="size-6" />
|
<img src={getTypeIcon()} alt={name} className="size-6 dark:drop-shadow-[0_0_6px_rgb(255,255,255)]" />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<span className="font-medium text-gray-900 dark:text-white">
|
<span className="font-medium text-gray-900 dark:text-white">
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { FC, memo, useCallback, useEffect, useState } from "react";
|
|||||||
import { Copy } from "lucide-react";
|
import { Copy } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
import { useDebounceFn } from "ahooks";
|
||||||
|
|
||||||
import { UserProfile } from "./UserProfile";
|
import { UserProfile } from "./UserProfile";
|
||||||
import { OpenURLWithBrowser } from "@/utils";
|
import { OpenURLWithBrowser } from "@/utils";
|
||||||
@@ -20,7 +21,6 @@ const ServiceAuth = memo(
|
|||||||
({ setRefreshLoading, refreshClick }: ServiceAuthProps) => {
|
({ setRefreshLoading, refreshClick }: ServiceAuthProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const language = useAppStore((state) => state.language);
|
|
||||||
const addError = useAppStore((state) => state.addError);
|
const addError = useAppStore((state) => state.addError);
|
||||||
const ssoRequestID = useAppStore((state) => state.ssoRequestID);
|
const ssoRequestID = useAppStore((state) => state.ssoRequestID);
|
||||||
const setSSORequestID = useAppStore((state) => state.setSSORequestID);
|
const setSSORequestID = useAppStore((state) => state.setSSORequestID);
|
||||||
@@ -42,7 +42,7 @@ const ServiceAuth = memo(
|
|||||||
// Generate the login URL with the current appUid
|
// Generate the login URL with the current appUid
|
||||||
const url = `${cloudSelectService?.auth_provider?.sso?.url}/?provider=${cloudSelectService?.id}&product=coco&request_id=${requestID}`;
|
const url = `${cloudSelectService?.auth_provider?.sso?.url}/?provider=${cloudSelectService?.id}&product=coco&request_id=${requestID}`;
|
||||||
|
|
||||||
console.log("Open SSO link, requestID:", ssoRequestID, url);
|
console.log("Open SSO link, requestID:", url);
|
||||||
|
|
||||||
// Open the URL in a browser
|
// Open the URL in a browser
|
||||||
OpenURLWithBrowser(url);
|
OpenURLWithBrowser(url);
|
||||||
@@ -61,19 +61,21 @@ const ServiceAuth = memo(
|
|||||||
[logoutServer]
|
[logoutServer]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const { run: debouncedAuthSuccess } = useDebounceFn((event) => {
|
||||||
|
const { serverId } = event.payload;
|
||||||
|
if (serverId) {
|
||||||
|
refreshClick(serverId, () => {
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
addError(t("cloud.connect.hints.loginSuccess"), "info");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// handle oauth success event
|
// handle oauth success event
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unlistenOAuth = platformAdapter.listenEvent(
|
const unlistenOAuth = platformAdapter.listenEvent(
|
||||||
"oauth_success",
|
"oauth_success",
|
||||||
(event) => {
|
debouncedAuthSuccess
|
||||||
const { serverId } = event.payload;
|
|
||||||
if (serverId) {
|
|
||||||
refreshClick(serverId, () => {
|
|
||||||
setLoading(false);
|
|
||||||
});
|
|
||||||
addError(language === "zh" ? "登录成功" : "Login Success", "info");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -163,7 +165,7 @@ const LoginButton: FC<LoginButtonProps> = memo((props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className="px-6 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 transition-colors mb-3"
|
className="px-6 py-2 bg-blue-500 text-white rounded-[6px] hover:bg-blue-600 transition-colors mb-3"
|
||||||
onClick={LoginClick}
|
onClick={LoginClick}
|
||||||
aria-label={t("cloud.login")}
|
aria-label={t("cloud.login")}
|
||||||
>
|
>
|
||||||
@@ -182,9 +184,9 @@ const LoadingState: FC<LoadingStateProps> = memo((props) => {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2 mb-3">
|
||||||
<button
|
<button
|
||||||
className="px-6 py-2 text-white bg-red-500 rounded-md hover:bg-red-600 transition-colors mb-3"
|
className="px-6 py-2 text-white bg-red-500 rounded-[6px] hover:bg-red-600 transition-colors"
|
||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
>
|
>
|
||||||
{t("cloud.cancel")}
|
{t("cloud.cancel")}
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export const Sidebar = forwardRef<{ refreshData: () => void }, SidebarProps>(
|
|||||||
<img
|
<img
|
||||||
src={item?.provider?.icon || cocoLogoImg}
|
src={item?.provider?.icon || cocoLogoImg}
|
||||||
alt={`${item.name} logo`}
|
alt={`${item.name} logo`}
|
||||||
className="w-5 h-5 flex-shrink-0"
|
className="w-5 h-5 flex-shrink-0 dark:drop-shadow-[0_0_6px_rgb(255,255,255)]"
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
const target = e.target as HTMLImageElement;
|
const target = e.target as HTMLImageElement;
|
||||||
target.src = cocoLogoImg;
|
target.src = cocoLogoImg;
|
||||||
|
|||||||
@@ -24,13 +24,13 @@ export function UserProfile({ server, userInfo, onLogout }: UserProfileProps) {
|
|||||||
<img
|
<img
|
||||||
src={userInfo?.avatar}
|
src={userInfo?.avatar}
|
||||||
alt=""
|
alt=""
|
||||||
className="w-6 h-6"
|
className="w-6 h-6 dark:drop-shadow-[0_0_6px_rgb(255,255,255)]"
|
||||||
onError={() => {
|
onError={() => {
|
||||||
setImageLoadError(true);
|
setImageLoadError(true);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<User className="w-6 h-6 text-gray-500 dark:text-gray-400" />
|
<User className="w-6 h-6 text-gray-500 dark:text-gray-400 dark:drop-shadow-[0_0_6px_rgb(255,255,255)]" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
|
|||||||
@@ -62,13 +62,13 @@ const ApiDetails: React.FC = () => {
|
|||||||
{logs.map((log, index) => (
|
{logs.map((log, index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
className="p-4 border rounded-md shadow-sm bg-gray-50"
|
className="p-4 border rounded-[6px] shadow-sm bg-gray-50"
|
||||||
>
|
>
|
||||||
<h4 className="font-semibold text-gray-800">
|
<h4 className="font-semibold text-gray-800">
|
||||||
Latest Request {index + 1}:
|
Latest Request {index + 1}:
|
||||||
</h4>
|
</h4>
|
||||||
<div className="text-sm text-gray-700 mt-1">
|
<div className="text-sm text-gray-700 mt-1">
|
||||||
<pre className="bg-gray-100 p-2 rounded-md whitespace-pre-wrap">
|
<pre className="bg-gray-100 p-2 rounded-[6px] whitespace-pre-wrap">
|
||||||
{JSON.stringify(log.request, null, 2)}
|
{JSON.stringify(log.request, null, 2)}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
@@ -87,7 +87,7 @@ const ApiDetails: React.FC = () => {
|
|||||||
</h4>
|
</h4>
|
||||||
{showIndex === index ? (
|
{showIndex === index ? (
|
||||||
<div className="text-sm text-gray-700 mt-1">
|
<div className="text-sm text-gray-700 mt-1">
|
||||||
<pre className="bg-green-100 p-2 rounded-md text-green-700 whitespace-pre-wrap">
|
<pre className="bg-green-100 p-2 rounded-[6px] text-green-700 whitespace-pre-wrap">
|
||||||
{JSON.stringify(log.response, null, 2)}
|
{JSON.stringify(log.response, null, 2)}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
@@ -98,7 +98,7 @@ const ApiDetails: React.FC = () => {
|
|||||||
<>
|
<>
|
||||||
<h4 className="font-semibold text-red-800 mt-4">Error:</h4>
|
<h4 className="font-semibold text-red-800 mt-4">Error:</h4>
|
||||||
<div className="text-sm text-gray-700 mt-1">
|
<div className="text-sm text-gray-700 mt-1">
|
||||||
<pre className="bg-red-100 p-2 rounded-md text-red-700 whitespace-pre-wrap">
|
<pre className="bg-red-100 p-2 rounded-[6px] text-red-700 whitespace-pre-wrap">
|
||||||
{JSON.stringify(log.error, null, 2)}
|
{JSON.stringify(log.error, null, 2)}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ const ChatSwitch: React.FC<ChatSwitchProps> = ({ isChatMode, onChange }) => {
|
|||||||
<div
|
<div
|
||||||
role="switch"
|
role="switch"
|
||||||
aria-checked={isChatMode}
|
aria-checked={isChatMode}
|
||||||
className={`relative flex items-center justify-between w-10 h-[18px] rounded-full cursor-pointer transition-colors duration-300 ${
|
className={`relative flex items-center justify-between w-10 h-[20px] rounded-full cursor-pointer transition-colors duration-300 ${
|
||||||
isChatMode ? "bg-[#0072ff]" : "bg-[var(--coco-primary-color)]"
|
isChatMode ? "bg-[#0072ff]" : "bg-[var(--coco-primary-color)]"
|
||||||
}`}
|
}`}
|
||||||
onClick={handleToggle}
|
onClick={handleToggle}
|
||||||
@@ -39,8 +39,8 @@ const ChatSwitch: React.FC<ChatSwitchProps> = ({ isChatMode, onChange }) => {
|
|||||||
{!isChatMode ? <Search className="w-4 h-4 text-white" /> : <div></div>}
|
{!isChatMode ? <Search className="w-4 h-4 text-white" /> : <div></div>}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={`absolute top-[1px] h-4 w-4 bg-white rounded-full shadow-md transform transition-transform duration-300 ${
|
className={`absolute top-[1px] left-[1px] h-[18px] w-[18px] bg-white rounded-full shadow-md transform transition-transform duration-300 ${
|
||||||
isChatMode ? "translate-x-6" : "translate-x-0"
|
isChatMode ? "translate-x-5" : "translate-x-0"
|
||||||
}`}
|
}`}
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const ErrorNotification = ({
|
|||||||
}: ErrorNotificationProps) => {
|
}: ErrorNotificationProps) => {
|
||||||
const errors = useAppStore((state) => state.errors);
|
const errors = useAppStore((state) => state.errors);
|
||||||
const removeError = useAppStore((state) => state.removeError);
|
const removeError = useAppStore((state) => state.removeError);
|
||||||
|
const suppressErrors = useAppStore((state) => state.suppressErrors);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!autoClose) return;
|
if (!autoClose) return;
|
||||||
@@ -32,7 +33,7 @@ const ErrorNotification = ({
|
|||||||
return () => clearInterval(timer);
|
return () => clearInterval(timer);
|
||||||
}, [errors, duration, autoClose]);
|
}, [errors, duration, autoClose]);
|
||||||
|
|
||||||
if (errors.length === 0) return null;
|
if (errors.length === 0 || suppressErrors) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ const HistoryListItem: FC<HistoryListItemProps> = ({
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={label}
|
key={label}
|
||||||
className="flex items-center gap-2 px-3 py-2 text-sm rounded-md hover:bg-[#EDEDED] dark:hover:bg-[#2B2C31] transition"
|
className="flex items-center gap-2 px-3 py-2 text-sm rounded-[6px] hover:bg-[#EDEDED] dark:hover:bg-[#2B2C31] transition"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
<VisibleKey shortcut={shortcut} onKeyPress={onClick}>
|
<VisibleKey shortcut={shortcut} onKeyPress={onClick}>
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ function CommonIcon({
|
|||||||
// Handle regular icon types
|
// Handle regular icon types
|
||||||
const renderIconByType = (renderType: string) => {
|
const renderIconByType = (renderType: string) => {
|
||||||
if (isNil(isAbsolute)) return null;
|
if (isNil(isAbsolute)) return null;
|
||||||
|
|
||||||
switch (renderType) {
|
switch (renderType) {
|
||||||
case "special_icon": {
|
case "special_icon": {
|
||||||
if (item.id === "Calculator") {
|
if (item.id === "Calculator") {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ interface FontIconProps {
|
|||||||
|
|
||||||
const FontIcon: FC<FontIconProps> = ({ name, className, style, ...rest }) => {
|
const FontIcon: FC<FontIconProps> = ({ name, className, style, ...rest }) => {
|
||||||
return (
|
return (
|
||||||
<svg className={`icon ${className || ""}`} style={style} {...rest}>
|
<svg className={`icon dark:drop-shadow-[0_0_6px_rgb(255,255,255)] ${className || ""}`} style={style} {...rest}>
|
||||||
<use xlinkHref={`#${name}`} />
|
<use xlinkHref={`#${name}`} />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -24,7 +24,12 @@ function ThemedIcon({ component: Component, className = "" }: ThemedIconProps) {
|
|||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return <Component className={className} color={color} />;
|
return (
|
||||||
|
<Component
|
||||||
|
className={`dark:drop-shadow-[0_0_6px_rgb(255,255,255)] ${className}`}
|
||||||
|
color={color}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ThemedIcon;
|
export default ThemedIcon;
|
||||||
|
|||||||
@@ -49,7 +49,13 @@ function UniversalIcon({
|
|||||||
|
|
||||||
// Render image type icon
|
// Render image type icon
|
||||||
const renderImageIcon = (src: string) => {
|
const renderImageIcon = (src: string) => {
|
||||||
const img = <img className={className} src={src} alt="icon" />;
|
const img = (
|
||||||
|
<img
|
||||||
|
className={`dark:drop-shadow-[0_0_6px_rgb(255,255,255)] ${className}`}
|
||||||
|
src={src}
|
||||||
|
alt="icon"
|
||||||
|
/>
|
||||||
|
);
|
||||||
return wrapWithIconWrapper ? (
|
return wrapWithIconWrapper ? (
|
||||||
<IconWrapper className={className} onClick={onClick}>
|
<IconWrapper className={className} onClick={onClick}>
|
||||||
{img}
|
{img}
|
||||||
@@ -63,7 +69,7 @@ function UniversalIcon({
|
|||||||
const renderAppIcon = (src: string) => {
|
const renderAppIcon = (src: string) => {
|
||||||
const img = (
|
const img = (
|
||||||
<img
|
<img
|
||||||
className={className}
|
className={`dark:drop-shadow-[0_0_6px_rgb(255,255,255)] ${className}`}
|
||||||
src={platformAdapter.convertFileSrc(src)}
|
src={platformAdapter.convertFileSrc(src)}
|
||||||
alt="icon"
|
alt="icon"
|
||||||
/>
|
/>
|
||||||
|
|||||||
47
src/components/Common/Scrollbar.tsx
Normal file
47
src/components/Common/Scrollbar.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { useEventListener } from "ahooks";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import {
|
||||||
|
forwardRef,
|
||||||
|
HTMLAttributes,
|
||||||
|
useImperativeHandle,
|
||||||
|
useRef,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
|
const Scrollbar = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
|
||||||
|
(props, ref) => {
|
||||||
|
const { children, className, ...rest } = props;
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => containerRef.current as HTMLDivElement);
|
||||||
|
|
||||||
|
useEventListener("keydown", (event) => {
|
||||||
|
const { key } = event;
|
||||||
|
|
||||||
|
if (key !== "PageDown" && key !== "PageUp") return;
|
||||||
|
|
||||||
|
if (!containerRef.current) return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const delta = key === "PageDown" ? 1 : -1;
|
||||||
|
const el = containerRef.current;
|
||||||
|
|
||||||
|
el.scrollBy({
|
||||||
|
top: delta * el.clientHeight * 0.9,
|
||||||
|
behavior: "smooth",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
{...rest}
|
||||||
|
ref={containerRef}
|
||||||
|
className={clsx("custom-scrollbar", className)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default Scrollbar;
|
||||||
50
src/components/Common/TogglePin.tsx
Normal file
50
src/components/Common/TogglePin.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { useAppStore } from "@/stores/appStore";
|
||||||
|
import { useShortcutsStore } from "@/stores/shortcutsStore";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import VisibleKey from "./VisibleKey";
|
||||||
|
import { FC, HTMLAttributes } from "react";
|
||||||
|
import PinOffIcon from "@/icons/PinOff";
|
||||||
|
import PinIcon from "@/icons/Pin";
|
||||||
|
|
||||||
|
interface TogglePinProps extends HTMLAttributes<HTMLButtonElement> {
|
||||||
|
setIsPinnedWeb?: (value: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TogglePin: FC<TogglePinProps> = (props) => {
|
||||||
|
const { className, setIsPinnedWeb } = props;
|
||||||
|
const { isPinned, setIsPinned } = useAppStore();
|
||||||
|
const { fixedWindow } = useShortcutsStore();
|
||||||
|
|
||||||
|
const togglePin = async () => {
|
||||||
|
const { isTauri, isPinned } = useAppStore.getState();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const nextPinned = !isPinned;
|
||||||
|
|
||||||
|
if (!isTauri) {
|
||||||
|
setIsPinnedWeb?.(nextPinned);
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsPinned(nextPinned);
|
||||||
|
} catch (err) {
|
||||||
|
setIsPinned(isPinned);
|
||||||
|
|
||||||
|
console.error("Failed to toggle window pin state:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={togglePin}
|
||||||
|
className={clsx(className, {
|
||||||
|
"text-blue-500": isPinned,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<VisibleKey shortcut={fixedWindow} onKeyPress={togglePin}>
|
||||||
|
{isPinned ? <PinIcon /> : <PinOffIcon />}
|
||||||
|
</VisibleKey>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TogglePin;
|
||||||
@@ -27,7 +27,7 @@ const Tooltip2: FC<Tooltip2Props> = (props) => {
|
|||||||
static
|
static
|
||||||
anchor={anchor}
|
anchor={anchor}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"fixed z-1000 p-2 rounded-md text-xs text-white bg-black/75 hidden",
|
"fixed z-1000 p-2 rounded-[6px] text-xs text-white bg-black/75 hidden",
|
||||||
{
|
{
|
||||||
"!block": visible,
|
"!block": visible,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,14 +4,10 @@ import { useTranslation } from "react-i18next";
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
|
||||||
import CommonIcon from "@/components/Common/Icons/CommonIcon";
|
import CommonIcon from "@/components/Common/Icons/CommonIcon";
|
||||||
import Copyright from "@/components/Common/Copyright";
|
|
||||||
import PinOffIcon from "@/icons/PinOff";
|
|
||||||
import PinIcon from "@/icons/Pin";
|
|
||||||
import logoImg from "@/assets/icon.svg";
|
import logoImg from "@/assets/icon.svg";
|
||||||
import { useAppStore } from "@/stores/appStore";
|
import { useAppStore } from "@/stores/appStore";
|
||||||
import { useSearchStore } from "@/stores/searchStore";
|
import { useSearchStore } from "@/stores/searchStore";
|
||||||
import { useUpdateStore } from "@/stores/updateStore";
|
import { useUpdateStore } from "@/stores/updateStore";
|
||||||
import VisibleKey from "../VisibleKey";
|
|
||||||
import { useShortcutsStore } from "@/stores/shortcutsStore";
|
import { useShortcutsStore } from "@/stores/shortcutsStore";
|
||||||
import { formatKey } from "@/utils/keyboardUtils";
|
import { formatKey } from "@/utils/keyboardUtils";
|
||||||
import source_default_img from "@/assets/images/source_default.png";
|
import source_default_img from "@/assets/images/source_default.png";
|
||||||
@@ -19,6 +15,8 @@ import source_default_dark_img from "@/assets/images/source_default_dark.png";
|
|||||||
import { useThemeStore } from "@/stores/themeStore";
|
import { useThemeStore } from "@/stores/themeStore";
|
||||||
import platformAdapter from "@/utils/platformAdapter";
|
import platformAdapter from "@/utils/platformAdapter";
|
||||||
import FontIcon from "../Icons/FontIcon";
|
import FontIcon from "../Icons/FontIcon";
|
||||||
|
import TogglePin from "../TogglePin";
|
||||||
|
import WebLogin from "@/components/WebLogin";
|
||||||
|
|
||||||
interface FooterProps {
|
interface FooterProps {
|
||||||
setIsPinnedWeb?: (value: boolean) => void;
|
setIsPinnedWeb?: (value: boolean) => void;
|
||||||
@@ -37,27 +35,11 @@ export default function Footer({ setIsPinnedWeb }: FooterProps) {
|
|||||||
|
|
||||||
const isDark = useThemeStore((state) => state.isDark);
|
const isDark = useThemeStore((state) => state.isDark);
|
||||||
|
|
||||||
const { isTauri, isPinned, setIsPinned } = useAppStore();
|
const { isTauri } = useAppStore();
|
||||||
|
|
||||||
const { setVisible, updateInfo, skipVersions } = useUpdateStore();
|
const { setVisible, updateInfo, skipVersions } = useUpdateStore();
|
||||||
|
|
||||||
const { fixedWindow, modifierKey } = useShortcutsStore();
|
const { modifierKey } = useShortcutsStore();
|
||||||
|
|
||||||
const setWindowAlwaysOnTop = useCallback(async (isPinned: boolean) => {
|
|
||||||
setIsPinnedWeb?.(isPinned);
|
|
||||||
return platformAdapter.setAlwaysOnTop(isPinned);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const togglePin = async () => {
|
|
||||||
try {
|
|
||||||
const newPinned = !isPinned;
|
|
||||||
await setWindowAlwaysOnTop(newPinned);
|
|
||||||
setIsPinned(newPinned);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to toggle window pin state:", err);
|
|
||||||
setIsPinned(isPinned);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const openSetting = useCallback(() => {
|
const openSetting = useCallback(() => {
|
||||||
return platformAdapter.emitEvent("open_settings", "");
|
return platformAdapter.emitEvent("open_settings", "");
|
||||||
@@ -67,7 +49,7 @@ export default function Footer({ setIsPinnedWeb }: FooterProps) {
|
|||||||
return updateInfo && !skipVersions.includes(updateInfo.version);
|
return updateInfo && !skipVersions.includes(updateInfo.version);
|
||||||
}, [updateInfo, skipVersions]);
|
}, [updateInfo, skipVersions]);
|
||||||
|
|
||||||
const renderLeft = () => {
|
const renderTauriLeft = () => {
|
||||||
if (sourceData?.source?.name) {
|
if (sourceData?.source?.name) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -87,7 +69,10 @@ export default function Footer({ setIsPinnedWeb }: FooterProps) {
|
|||||||
if (visibleExtensionDetail && selectedExtension) {
|
if (visibleExtensionDetail && selectedExtension) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<img src={selectedExtension.icon} className="size-5" />
|
<img
|
||||||
|
src={selectedExtension.icon}
|
||||||
|
className="size-5 dark:drop-shadow-[0_0_6px_rgb(255,255,255)]"
|
||||||
|
/>
|
||||||
<span className="text-sm">{selectedExtension.name}</span>
|
<span className="text-sm">{selectedExtension.name}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -131,28 +116,28 @@ export default function Footer({ setIsPinnedWeb }: FooterProps) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-tauri-drag-region={isTauri}
|
data-tauri-drag-region={isTauri}
|
||||||
className="px-4 z-999 mx-[1px] h-8 absolute bottom-0 left-0 right-0 border-t border-gray-200 dark:border-gray-700 flex items-center justify-between rounded-md rounded-t-none overflow-hidden"
|
className={clsx(
|
||||||
|
"px-4 z-999 mx-[1px] h-8 absolute bottom-0 left-0 right-0 border-t border-gray-200 dark:border-gray-700 flex items-center justify-between rounded-[6px] rounded-t-none",
|
||||||
|
{
|
||||||
|
"overflow-hidden": isTauri,
|
||||||
|
}
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{isTauri ? (
|
{isTauri ? (
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
{renderLeft()}
|
{renderTauriLeft()}
|
||||||
|
|
||||||
<button
|
<TogglePin
|
||||||
onClick={togglePin}
|
|
||||||
className={clsx({
|
className={clsx({
|
||||||
"text-blue-500": isPinned,
|
|
||||||
"pl-2": hasUpdate,
|
"pl-2": hasUpdate,
|
||||||
})}
|
})}
|
||||||
>
|
setIsPinnedWeb={setIsPinnedWeb}
|
||||||
<VisibleKey shortcut={fixedWindow} onKeyPress={togglePin}>
|
/>
|
||||||
{isPinned ? <PinIcon /> : <PinOffIcon />}
|
|
||||||
</VisibleKey>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Copyright />
|
<WebLogin panelClassName="bottom-5 left-0" />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={`flex mobile:hidden items-center gap-3`}>
|
<div className={`flex mobile:hidden items-center gap-3`}>
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ import { useShortcutsStore } from "@/stores/shortcutsStore";
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { formatKey } from "@/utils/keyboardUtils";
|
import { formatKey } from "@/utils/keyboardUtils";
|
||||||
import SearchEmpty from "../SearchEmpty";
|
import SearchEmpty from "../SearchEmpty";
|
||||||
|
import FontIcon from "../Icons/FontIcon";
|
||||||
|
import WebLoginButton from "@/components/WebLogin/LoginButton";
|
||||||
|
import WebRefreshButton from "@/components/WebLogin/RefreshButton";
|
||||||
|
import { useWebConfigStore } from "@/stores/webConfigStore";
|
||||||
|
import { useAppStore } from "@/stores/appStore";
|
||||||
|
|
||||||
export const NoResults = () => {
|
export const NoResults = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -12,33 +17,66 @@ export const NoResults = () => {
|
|||||||
const modifierKey = useShortcutsStore((state) => state.modifierKey);
|
const modifierKey = useShortcutsStore((state) => state.modifierKey);
|
||||||
const modeSwitch = useShortcutsStore((state) => state.modeSwitch);
|
const modeSwitch = useShortcutsStore((state) => state.modeSwitch);
|
||||||
|
|
||||||
|
const { isTauri } = useAppStore();
|
||||||
|
const { disabled } = useWebConfigStore();
|
||||||
|
|
||||||
|
const renderContent = () => {
|
||||||
|
if (!isTauri && disabled) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-4 text-sm">
|
||||||
|
<FontIcon
|
||||||
|
name="font_coco-logo-line"
|
||||||
|
className="size-20 text-[#999]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<p>{t("webLogin.hints.welcome")}</p>
|
||||||
|
<p>{t("webLogin.hints.pleaseLogin")}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<WebLoginButton />
|
||||||
|
|
||||||
|
<WebRefreshButton className="size-8" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SearchEmpty />
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`flex mobile:hidden mt-10 text-sm text-[#333] dark:text-[#D8D8D8]`}
|
||||||
|
>
|
||||||
|
{t("search.main.askCoco")}
|
||||||
|
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
"ml-3 h-5 min-w-5 rounded-[6px] border border-[#D8D8D8] flex justify-center items-center",
|
||||||
|
{
|
||||||
|
"px-1": !isMac,
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formatKey(modifierKey)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="ml-1 w-5 h-5 rounded-[6px] border border-[#D8D8D8] flex justify-center items-center">
|
||||||
|
{modeSwitch}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-tauri-drag-region
|
data-tauri-drag-region
|
||||||
className="h-full w-full flex flex-col justify-center items-center"
|
className="h-full w-full flex flex-col justify-center items-center"
|
||||||
>
|
>
|
||||||
<SearchEmpty />
|
{renderContent()}
|
||||||
|
|
||||||
<div
|
|
||||||
className={`flex mobile:hidden mt-10 text-sm text-[#333] dark:text-[#D8D8D8]`}
|
|
||||||
>
|
|
||||||
{t("search.main.askCoco")}
|
|
||||||
|
|
||||||
<span
|
|
||||||
className={clsx(
|
|
||||||
"ml-3 h-5 min-w-5 rounded-md border border-[#D8D8D8] flex justify-center items-center",
|
|
||||||
{
|
|
||||||
"px-1": !isMac,
|
|
||||||
}
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{formatKey(modifierKey)}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span className="ml-1 w-5 h-5 rounded-[6px] border border-[#D8D8D8] flex justify-center items-center">
|
|
||||||
{modeSwitch}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ const Footer = () => {
|
|||||||
active
|
active
|
||||||
? "bg-gray-100 dark:bg-gray-700"
|
? "bg-gray-100 dark:bg-gray-700"
|
||||||
: "text-gray-900 dark:text-gray-100"
|
: "text-gray-900 dark:text-gray-100"
|
||||||
} group flex w-full items-center rounded-md px-3 py-2 text-sm`}
|
} group flex w-full items-center rounded-[6px] px-3 py-2 text-sm`}
|
||||||
>
|
>
|
||||||
<Home className="w-4 h-4 mr-2" />
|
<Home className="w-4 h-4 mr-2" />
|
||||||
<Link to={`/`}>Home</Link>
|
<Link to={`/`}>Home</Link>
|
||||||
@@ -41,7 +41,7 @@ const Footer = () => {
|
|||||||
active
|
active
|
||||||
? "bg-gray-100 dark:bg-gray-700"
|
? "bg-gray-100 dark:bg-gray-700"
|
||||||
: "text-gray-900 dark:text-gray-100"
|
: "text-gray-900 dark:text-gray-100"
|
||||||
} group flex w-full items-center rounded-md px-3 py-2 text-sm`}
|
} group flex w-full items-center rounded-[6px] px-3 py-2 text-sm`}
|
||||||
>
|
>
|
||||||
<User className="w-4 h-4 mr-2" />
|
<User className="w-4 h-4 mr-2" />
|
||||||
Profile
|
Profile
|
||||||
@@ -55,7 +55,7 @@ const Footer = () => {
|
|||||||
active
|
active
|
||||||
? "bg-gray-100 dark:bg-gray-700"
|
? "bg-gray-100 dark:bg-gray-700"
|
||||||
: "text-gray-900 dark:text-gray-100"
|
: "text-gray-900 dark:text-gray-100"
|
||||||
} group flex w-full items-center rounded-md px-3 py-2 text-sm`}
|
} group flex w-full items-center rounded-[6px] px-3 py-2 text-sm`}
|
||||||
>
|
>
|
||||||
<Settings className="w-4 h-4 mr-2" />
|
<Settings className="w-4 h-4 mr-2" />
|
||||||
<Link to={`settings`}>Settings</Link>
|
<Link to={`settings`}>Settings</Link>
|
||||||
@@ -70,7 +70,7 @@ const Footer = () => {
|
|||||||
active
|
active
|
||||||
? "bg-gray-100 dark:bg-gray-700"
|
? "bg-gray-100 dark:bg-gray-700"
|
||||||
: "text-gray-900 dark:text-gray-100"
|
: "text-gray-900 dark:text-gray-100"
|
||||||
} group flex w-full items-center rounded-md px-3 py-2 text-sm`}
|
} group flex w-full items-center rounded-[6px] px-3 py-2 text-sm`}
|
||||||
>
|
>
|
||||||
<LogOut className="w-4 h-4 mr-2" />
|
<LogOut className="w-4 h-4 mr-2" />
|
||||||
Sign Out
|
Sign Out
|
||||||
@@ -95,4 +95,4 @@ const Footer = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Footer;
|
export default Footer;
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import { last } from "lodash-es";
|
|||||||
import { POPOVER_PANEL_SELECTOR } from "@/constants";
|
import { POPOVER_PANEL_SELECTOR } from "@/constants";
|
||||||
import { useShortcutsStore } from "@/stores/shortcutsStore";
|
import { useShortcutsStore } from "@/stores/shortcutsStore";
|
||||||
import { useAppStore } from "@/stores/appStore";
|
import { useAppStore } from "@/stores/appStore";
|
||||||
|
import { KeyType } from "ahooks/lib/useKeyPress";
|
||||||
|
|
||||||
|
const keyTriggerMap = new Map<KeyType, number>();
|
||||||
|
|
||||||
interface VisibleKeyProps extends HTMLAttributes<HTMLDivElement> {
|
interface VisibleKeyProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
shortcut: string;
|
shortcut: string;
|
||||||
@@ -60,8 +63,16 @@ const VisibleKey: FC<VisibleKeyProps> = (props) => {
|
|||||||
setVisibleShortcut(isChildInPopover && modifierKeyPressed);
|
setVisibleShortcut(isChildInPopover && modifierKeyPressed);
|
||||||
}, [openPopover, modifierKeyPressed]);
|
}, [openPopover, modifierKeyPressed]);
|
||||||
|
|
||||||
useKeyPress(`${modifierKey}.${shortcut}`, (event) => {
|
useKeyPress(`${modifierKey}.${shortcut}`, (event, key) => {
|
||||||
if (!visibleShortcut) return;
|
if (!visibleShortcut || event.repeat) return;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const last = keyTriggerMap.get(key) ?? 0;
|
||||||
|
const wait = 100;
|
||||||
|
|
||||||
|
if (now - last < wait) return;
|
||||||
|
|
||||||
|
keyTriggerMap.set(key, now);
|
||||||
|
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -82,6 +93,10 @@ const VisibleKey: FC<VisibleKeyProps> = (props) => {
|
|||||||
return "↩︎";
|
return "↩︎";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (shortcut === "backspace") {
|
||||||
|
return "⌫";
|
||||||
|
}
|
||||||
|
|
||||||
return shortcut;
|
return shortcut;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -96,7 +111,7 @@ const VisibleKey: FC<VisibleKeyProps> = (props) => {
|
|||||||
{showTooltip && visibleShortcut ? (
|
{showTooltip && visibleShortcut ? (
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"size-4 flex items-center justify-center font-normal text-xs text-[#333] leading-[14px] bg-[#ccc] dark:bg-[#6B6B6B] rounded-md shadow-[-6px_0px_6px_2px_#fff] dark:shadow-[-6px_0px_6px_2px_#000] absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2",
|
"size-4 flex items-center justify-center font-normal text-xs text-[#333] leading-[14px] bg-[#ccc] dark:bg-[#6B6B6B] rounded-[6px] shadow-[-6px_0px_6px_2px_#fff] dark:shadow-[-6px_0px_6px_2px_#000] absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2",
|
||||||
shortcutClassName
|
shortcutClassName
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ const AiOverview: FC<AiSummaryProps> = (props) => {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="absolute top-2 right-2 flex items-center justify-center size-[20px] border rounded-md cursor-pointer dark:border-[#282828]"
|
className="absolute top-2 right-2 flex items-center justify-center size-[20px] border rounded-[6px] cursor-pointer dark:border-[#282828]"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setVisible(false);
|
setVisible(false);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ const AskAi: FC<AskAiProps> = (props) => {
|
|||||||
unlisten.current = await platformAdapter.listenEvent(
|
unlisten.current = await platformAdapter.listenEvent(
|
||||||
"quick-ai-access-client-id",
|
"quick-ai-access-client-id",
|
||||||
({ payload }) => {
|
({ payload }) => {
|
||||||
console.log("ask_ai", JSON.parse(payload));
|
// console.log("ask_ai", JSON.parse(payload));
|
||||||
|
|
||||||
const chunkData = JSON.parse(payload);
|
const chunkData = JSON.parse(payload);
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import platformAdapter from "@/utils/platformAdapter";
|
|||||||
import { Get } from "@/api/axiosRequest";
|
import { Get } from "@/api/axiosRequest";
|
||||||
import type { Assistant } from "@/types/chat";
|
import type { Assistant } from "@/types/chat";
|
||||||
import { useAppStore } from "@/stores/appStore";
|
import { useAppStore } from "@/stores/appStore";
|
||||||
|
import { canNavigateBack, navigateBack } from "@/utils";
|
||||||
|
import { useKeyPress } from "ahooks";
|
||||||
|
import { useShortcutsStore } from "@/stores/shortcutsStore";
|
||||||
|
|
||||||
interface AssistantManagerProps {
|
interface AssistantManagerProps {
|
||||||
isChatMode: boolean;
|
isChatMode: boolean;
|
||||||
@@ -33,9 +36,9 @@ export function useAssistantManager({
|
|||||||
setVisibleExtensionStore,
|
setVisibleExtensionStore,
|
||||||
setSearchValue,
|
setSearchValue,
|
||||||
visibleExtensionDetail,
|
visibleExtensionDetail,
|
||||||
setVisibleExtensionDetail,
|
|
||||||
sourceData,
|
sourceData,
|
||||||
setSourceData,
|
setSourceData,
|
||||||
|
setVisibleExtensionDetail,
|
||||||
} = useSearchStore();
|
} = useSearchStore();
|
||||||
|
|
||||||
const { quickAiAccessAssistant, disabledExtensions } = useExtensionsStore();
|
const { quickAiAccessAssistant, disabledExtensions } = useExtensionsStore();
|
||||||
@@ -47,6 +50,7 @@ export function useAssistantManager({
|
|||||||
}, [quickAiAccessAssistant, selectedAssistant]);
|
}, [quickAiAccessAssistant, selectedAssistant]);
|
||||||
|
|
||||||
const [assistantDetail, setAssistantDetail] = useState<any>({});
|
const [assistantDetail, setAssistantDetail] = useState<any>({});
|
||||||
|
const { modifierKey } = useShortcutsStore();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (goAskAi) return;
|
if (goAskAi) return;
|
||||||
@@ -99,39 +103,14 @@ export function useAssistantManager({
|
|||||||
const handleKeyDownAutoResizeTextarea = useCallback(
|
const handleKeyDownAutoResizeTextarea = useCallback(
|
||||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
const { key, shiftKey, currentTarget } = e;
|
const { key, shiftKey, currentTarget } = e;
|
||||||
const { value } = currentTarget;
|
const { value, selectionStart, selectionEnd } = currentTarget;
|
||||||
|
|
||||||
if (key === "Backspace" && value === "") {
|
const cursorStart = selectionStart === 0 && selectionEnd === 0;
|
||||||
if (goAskAi) {
|
|
||||||
return setGoAskAi(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (visibleExtensionDetail) {
|
if (key === "Backspace" && (value === "" || cursorStart)) {
|
||||||
return setVisibleExtensionDetail(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (visibleExtensionStore) {
|
|
||||||
return setVisibleExtensionStore(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sourceData) {
|
|
||||||
return setSourceData(void 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (key === "Tab" && !isChatMode && isTauri) {
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
if (visibleExtensionStore) return;
|
return navigateBack();
|
||||||
|
|
||||||
if (selectedSearchContent?.id === "Extension Store") {
|
|
||||||
changeInput("");
|
|
||||||
setSearchValue("");
|
|
||||||
return setVisibleExtensionStore(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
assistant_get();
|
|
||||||
return handleAskAi();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (key === "Enter" && !shiftKey) {
|
if (key === "Enter" && !shiftKey) {
|
||||||
@@ -147,6 +126,17 @@ export function useAssistantManager({
|
|||||||
|
|
||||||
handleSubmit();
|
handleSubmit();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (key === "Home") {
|
||||||
|
e.preventDefault();
|
||||||
|
return currentTarget.setSelectionRange(0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === "End") {
|
||||||
|
e.preventDefault();
|
||||||
|
const length = currentTarget.value.length;
|
||||||
|
return currentTarget.setSelectionRange(length, length);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
isChatMode,
|
isChatMode,
|
||||||
@@ -161,6 +151,66 @@ export function useAssistantManager({
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const clearSearchValue = () => {
|
||||||
|
changeInput("");
|
||||||
|
setSearchValue("");
|
||||||
|
};
|
||||||
|
|
||||||
|
// useKeyPress("backspace", () => {
|
||||||
|
// console.log("backspace");
|
||||||
|
// dispatchEvent("Backspace", 8, "#search-textarea");
|
||||||
|
// });
|
||||||
|
|
||||||
|
useKeyPress("tab", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const { selectedSearchContent, visibleExtensionStore } =
|
||||||
|
useSearchStore.getState();
|
||||||
|
|
||||||
|
console.log("selectedSearchContent", selectedSearchContent);
|
||||||
|
|
||||||
|
const { id, type, category } = selectedSearchContent ?? {};
|
||||||
|
|
||||||
|
if (isChatMode || !isTauri || id === "Calculator") return;
|
||||||
|
|
||||||
|
if (visibleExtensionStore) {
|
||||||
|
clearSearchValue();
|
||||||
|
return setVisibleExtensionDetail(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (id === "Extension Store") {
|
||||||
|
clearSearchValue();
|
||||||
|
return setVisibleExtensionStore(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (category === "View") {
|
||||||
|
const onOpened = selectedSearchContent?.on_opened;
|
||||||
|
|
||||||
|
if (onOpened?.Extension?.ty?.View) {
|
||||||
|
clearSearchValue();
|
||||||
|
return platformAdapter.invokeBackend("open", {
|
||||||
|
onOpened: onOpened,
|
||||||
|
extraArgs: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "AI Assistant") {
|
||||||
|
assistant_get();
|
||||||
|
return handleAskAi();
|
||||||
|
}
|
||||||
|
|
||||||
|
setSourceData(selectedSearchContent);
|
||||||
|
});
|
||||||
|
|
||||||
|
useKeyPress(`${modifierKey}.enter`, () => {
|
||||||
|
if (canNavigateBack()) return;
|
||||||
|
|
||||||
|
assistant_get();
|
||||||
|
|
||||||
|
return handleAskAi();
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
askAI,
|
askAI,
|
||||||
askAIRef,
|
askAIRef,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user