mirror of
https://github.com/infinilabs/coco-app.git
synced 2025-12-24 07:19:23 +01:00
Compare commits
85 Commits
microphone
...
fix-macos-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a05ca9646d | ||
|
|
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 | ||
|
|
5dae5d1cc1 | ||
|
|
23372655ca | ||
|
|
f5b33af7f1 | ||
|
|
993da9a8ad | ||
|
|
93f1024230 | ||
|
|
7b5e528060 | ||
|
|
1d5ba3ab07 | ||
|
|
f93c527561 | ||
|
|
6065353ac9 | ||
|
|
783cb73b29 | ||
|
|
ee75f0d119 | ||
|
|
aaac874f2c | ||
|
|
cd9e454991 | ||
|
|
d0fc79238b | ||
|
|
3ed84c2318 | ||
|
|
bd039398ba | ||
|
|
568db6aba0 | ||
|
|
2eb10933e7 | ||
|
|
5c6cf18139 | ||
|
|
01c31d884a |
70
.github/workflows/frontend-ci.yml
vendored
Normal file
70
.github/workflows/frontend-ci.yml
vendored
Normal file
@@ -0,0 +1,70 @@
|
||||
name: Frontend Code Check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
# Only run it when Frontend code changes
|
||||
paths:
|
||||
- 'src/**'
|
||||
- 'tsup.config.ts'
|
||||
- 'package.json'
|
||||
|
||||
jobs:
|
||||
check:
|
||||
strategy:
|
||||
matrix:
|
||||
platform: [ubuntu-latest, windows-latest, macos-latest]
|
||||
|
||||
runs-on: ${{ matrix.platform }}
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.head_ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
# No need to pass the version arg as it is specified by "packageManager" in package.json
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: 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
|
||||
run: pnpm build
|
||||
12
.github/workflows/release.yml
vendored
12
.github/workflows/release.yml
vendored
@@ -104,7 +104,17 @@ jobs:
|
||||
if: startsWith(matrix.platform, 'ubuntu-22.04')
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf xdg-utils
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf xdg-utils libtracker-sparql-3.0-dev
|
||||
|
||||
|
||||
# On Windows, we need to generate bindings for 'searchapi.h' using bindgen.
|
||||
# And bindgen relies on 'libclang'
|
||||
# https://rust-lang.github.io/rust-bindgen/requirements.html#windows
|
||||
- name: Install dependencies (Windows only)
|
||||
if: startsWith(matrix.platform, 'windows-latest')
|
||||
shell: bash
|
||||
run: winget install LLVM.LLVM --silent --accept-package-agreements --accept-source-agreements
|
||||
|
||||
|
||||
- name: Add Rust build target
|
||||
working-directory: src-tauri
|
||||
|
||||
10
.github/workflows/rust_code_check.yml
vendored
10
.github/workflows/rust_code_check.yml
vendored
@@ -30,7 +30,15 @@ jobs:
|
||||
if: startsWith(matrix.platform, 'ubuntu-latest')
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf xdg-utils
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf xdg-utils libtracker-sparql-3.0-dev
|
||||
|
||||
# On Windows, we need to generate bindings for 'searchapi.h' using bindgen.
|
||||
# And bindgen relies on 'libclang'
|
||||
# https://rust-lang.github.io/rust-bindgen/requirements.html#windows
|
||||
- name: Install dependencies (Windows only)
|
||||
if: startsWith(matrix.platform, 'windows-latest')
|
||||
shell: bash
|
||||
run: winget install LLVM.LLVM --silent --accept-package-agreements --accept-source-agreements
|
||||
|
||||
- name: Add pizza engine as a dependency
|
||||
working-directory: src-tauri
|
||||
|
||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -83,5 +83,6 @@
|
||||
"i18n-ally.keystyle": "nested",
|
||||
"editor.tabSize": 2,
|
||||
"editor.insertSpaces": true,
|
||||
"editor.detectIndentation": false
|
||||
"editor.detectIndentation": false,
|
||||
"i18n-ally.displayLanguage": "zh"
|
||||
}
|
||||
@@ -64,9 +64,9 @@ At Coco AI, we aim to streamline workplace collaboration by centralizing access
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js >= 18.12
|
||||
- Rust (latest stable)
|
||||
- pnpm (package manager)
|
||||
- [Node.js >= 18.12](https://nodejs.org/en/download/)
|
||||
- [Rust (latest stable)](https://www.rust-lang.org/tools/install)
|
||||
- [pnpm (package manager)](https://pnpm.io/installation)
|
||||
|
||||
### Development Setup
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ Coco AI is a fully open-source, cross-platform unified search and productivity t
|
||||
|
||||
{{% 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
|
||||
|
||||
|
||||
@@ -13,6 +13,12 @@ asciinema: true
|
||||
[x11_protocol]: https://en.wikipedia.org/wiki/X_Window_System
|
||||
[if_x11]: https://unix.stackexchange.com/q/202891/498440
|
||||
|
||||
## Install dependencies
|
||||
|
||||
```sh
|
||||
$ sudo apt-get update
|
||||
$ sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf xdg-utils libtracker-sparql-3.0-dev
|
||||
```
|
||||
|
||||
## Go to the download page
|
||||
|
||||
|
||||
@@ -13,14 +13,72 @@ Information about release notes of Coco App is provided here.
|
||||
|
||||
### 🚀 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
|
||||
|
||||
### 🐛 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
|
||||
|
||||
### ✈️ 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
|
||||
|
||||
## 0.8.0 (2025-09-28)
|
||||
|
||||
### ❌ Breaking changes
|
||||
|
||||
- chore: update request accesstoken api #866
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
- feat: enhance ui for skipped version #834
|
||||
- feat: support installing local extensions #749
|
||||
- feat: support sending files in chat messages #764
|
||||
- feat: sub extension can set 'platforms' now #847
|
||||
- feat: add extension uninstall option in settings #855
|
||||
- feat: impl extension settings 'hide_before_open' #862
|
||||
- feat: index both en/zh_CN app names and show app name in chosen language #875
|
||||
- feat: support context menu in debug mode #882
|
||||
- feat: file search for Linux/GNOME #884
|
||||
- feat: file search for Linux/KDE #886
|
||||
- feat: extension Window Management for macOS #892
|
||||
- feat: new extension type View #894
|
||||
- feat: support opening file in its containing folder #900
|
||||
|
||||
### 🐛 Bug fix
|
||||
|
||||
- fix: fix issue with update check failure #833
|
||||
- fix: web component login state #857
|
||||
- fix: shortcut key not opening extension store #877
|
||||
- fix: set up hotkey on main thread or Windows will complain #879
|
||||
- fix: resolve deeplink login issue #881
|
||||
- fix: use kill_on_drop() to avoid zombie proc in error case #887
|
||||
- fix: settings window rendering/loading issue 889
|
||||
- fix: ensure search paths are indexed #896
|
||||
- fix: bump applications-rs to fix empty app name issue #898
|
||||
|
||||
### ✈️ Improvements
|
||||
|
||||
@@ -30,6 +88,15 @@ Information about release notes of Coco App is provided here.
|
||||
- chore: delete unused code files and dependencies #841
|
||||
- chore: ignore tauri::AppHandle's generic argument R #845
|
||||
- refactor: check Extension/plugin.json from all sources #846
|
||||
- refactor: pinning window won't set CanJoinAllSpaces on macOS #854
|
||||
- build: web component build error #858
|
||||
- refactor: coordinate third-party extension operations using lock #867
|
||||
- refactor: index iOS apps and macOS apps that store icon in Assets.car #872
|
||||
- refactor: accept both '-' and '\_' as locale str separator #876
|
||||
- refactor: relax the file search conditions on macOS #883
|
||||
- refactor: ensure Coco won't take focus #891
|
||||
- chore: skip login check for web widget #895
|
||||
- chore: convertFileSrc() "link[href]" and "img[src]" #901
|
||||
|
||||
## 0.7.1 (2025-07-27)
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "coco",
|
||||
"private": true,
|
||||
"version": "0.7.1",
|
||||
"version": "0.8.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -26,7 +26,7 @@
|
||||
"@tauri-apps/plugin-global-shortcut": "~2.0.0",
|
||||
"@tauri-apps/plugin-http": "~2.0.2",
|
||||
"@tauri-apps/plugin-log": "~2.4.0",
|
||||
"@tauri-apps/plugin-opener": "^2.2.7",
|
||||
"@tauri-apps/plugin-opener": "^2.5.0",
|
||||
"@tauri-apps/plugin-os": "^2.2.1",
|
||||
"@tauri-apps/plugin-process": "^2.2.1",
|
||||
"@tauri-apps/plugin-shell": "^2.2.1",
|
||||
@@ -93,4 +93,4 @@
|
||||
"vite": "^5.4.19"
|
||||
},
|
||||
"packageManager": "pnpm@10.11.0+sha512.6540583f41cc5f628eb3d9773ecee802f4f9ef9923cc45b69890fb47991d4b092964694ec3a4f738a420c918a333062c8b925d312f42e4f0c263eb603551f977"
|
||||
}
|
||||
}
|
||||
|
||||
63
pnpm-lock.yaml
generated
63
pnpm-lock.yaml
generated
@@ -33,8 +33,8 @@ importers:
|
||||
specifier: ~2.4.0
|
||||
version: 2.4.0
|
||||
'@tauri-apps/plugin-opener':
|
||||
specifier: ^2.2.7
|
||||
version: 2.2.7
|
||||
specifier: ^2.5.0
|
||||
version: 2.5.0
|
||||
'@tauri-apps/plugin-os':
|
||||
specifier: ^2.2.1
|
||||
version: 2.2.1
|
||||
@@ -790,6 +790,9 @@ packages:
|
||||
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
'@jridgewell/gen-mapping@0.3.13':
|
||||
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
|
||||
|
||||
'@jridgewell/gen-mapping@0.3.8':
|
||||
resolution: {integrity: sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
@@ -802,15 +805,21 @@ packages:
|
||||
resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
|
||||
'@jridgewell/source-map@0.3.6':
|
||||
resolution: {integrity: sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==}
|
||||
'@jridgewell/source-map@0.3.11':
|
||||
resolution: {integrity: sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==}
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.5.0':
|
||||
resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==}
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.5.5':
|
||||
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
|
||||
|
||||
'@jridgewell/trace-mapping@0.3.25':
|
||||
resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==}
|
||||
|
||||
'@jridgewell/trace-mapping@0.3.31':
|
||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
||||
|
||||
'@mermaid-js/parser@0.4.0':
|
||||
resolution: {integrity: sha512-wla8XOWvQAwuqy+gxiZqY+c7FokraOTHRWMsbB4AgRx9Sy7zKslNyejy7E+a77qHfey5GXw/ik3IXv/NHMJgaA==}
|
||||
|
||||
@@ -1153,6 +1162,9 @@ packages:
|
||||
'@tauri-apps/api@2.5.0':
|
||||
resolution: {integrity: sha512-Ldux4ip+HGAcPUmuLT8EIkk6yafl5vK0P0c0byzAKzxJh7vxelVtdPONjfgTm96PbN24yjZNESY8CKo8qniluA==}
|
||||
|
||||
'@tauri-apps/api@2.8.0':
|
||||
resolution: {integrity: sha512-ga7zdhbS2GXOMTIZRT0mYjKJtR9fivsXzsyq5U3vjDL0s6DTMwYRm0UHNjzTY5dh4+LSC68Sm/7WEiimbQNYlw==}
|
||||
|
||||
'@tauri-apps/cli-darwin-arm64@2.5.0':
|
||||
resolution: {integrity: sha512-VuVAeTFq86dfpoBDNYAdtQVLbP0+2EKCHIIhkaxjeoPARR0sLpFHz2zs0PcFU76e+KAaxtEtAJAXGNUc8E1PzQ==}
|
||||
engines: {node: '>= 10'}
|
||||
@@ -1242,8 +1254,8 @@ packages:
|
||||
'@tauri-apps/plugin-log@2.4.0':
|
||||
resolution: {integrity: sha512-j7yrDtLNmayCBOO2esl3aZv9jSXy2an8MDLry3Ys9ZXerwUg35n1Y2uD8HoCR+8Ng/EUgx215+qOUfJasjYrHw==}
|
||||
|
||||
'@tauri-apps/plugin-opener@2.2.7':
|
||||
resolution: {integrity: sha512-uduEyvOdjpPOEeDRrhwlCspG/f9EQalHumWBtLBnp3fRp++fKGLqDOyUhSIn7PzX45b/rKep//ZQSAQoIxobLA==}
|
||||
'@tauri-apps/plugin-opener@2.5.0':
|
||||
resolution: {integrity: sha512-B0LShOYae4CZjN8leiNDbnfjSrTwoZakqKaWpfoH6nXiJwt6Rgj6RnVIffG3DoJiKsffRhMkjmBV9VeilSb4TA==}
|
||||
|
||||
'@tauri-apps/plugin-os@2.2.1':
|
||||
resolution: {integrity: sha512-cNYpNri2CCc6BaNeB6G/mOtLvg8dFyFQyCUdf2y0K8PIAKGEWdEcu8DECkydU2B+oj4OJihDPD2de5K6cbVl9A==}
|
||||
@@ -1464,6 +1476,11 @@ packages:
|
||||
engines: {node: '>=0.4.0'}
|
||||
hasBin: true
|
||||
|
||||
acorn@8.15.0:
|
||||
resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
hasBin: true
|
||||
|
||||
agent-base@7.1.3:
|
||||
resolution: {integrity: sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==}
|
||||
engines: {node: '>= 14'}
|
||||
@@ -4230,6 +4247,12 @@ snapshots:
|
||||
wrap-ansi: 8.1.0
|
||||
wrap-ansi-cjs: wrap-ansi@7.0.0
|
||||
|
||||
'@jridgewell/gen-mapping@0.3.13':
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
optional: true
|
||||
|
||||
'@jridgewell/gen-mapping@0.3.8':
|
||||
dependencies:
|
||||
'@jridgewell/set-array': 1.2.1
|
||||
@@ -4240,19 +4263,28 @@ snapshots:
|
||||
|
||||
'@jridgewell/set-array@1.2.1': {}
|
||||
|
||||
'@jridgewell/source-map@0.3.6':
|
||||
'@jridgewell/source-map@0.3.11':
|
||||
dependencies:
|
||||
'@jridgewell/gen-mapping': 0.3.8
|
||||
'@jridgewell/trace-mapping': 0.3.25
|
||||
'@jridgewell/gen-mapping': 0.3.13
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
optional: true
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.5.0': {}
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.5.5':
|
||||
optional: true
|
||||
|
||||
'@jridgewell/trace-mapping@0.3.25':
|
||||
dependencies:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.5.0
|
||||
|
||||
'@jridgewell/trace-mapping@0.3.31':
|
||||
dependencies:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
optional: true
|
||||
|
||||
'@mermaid-js/parser@0.4.0':
|
||||
dependencies:
|
||||
langium: 3.3.1
|
||||
@@ -4546,6 +4578,8 @@ snapshots:
|
||||
|
||||
'@tauri-apps/api@2.5.0': {}
|
||||
|
||||
'@tauri-apps/api@2.8.0': {}
|
||||
|
||||
'@tauri-apps/cli-darwin-arm64@2.5.0':
|
||||
optional: true
|
||||
|
||||
@@ -4617,9 +4651,9 @@ snapshots:
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.5.0
|
||||
|
||||
'@tauri-apps/plugin-opener@2.2.7':
|
||||
'@tauri-apps/plugin-opener@2.5.0':
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.5.0
|
||||
'@tauri-apps/api': 2.8.0
|
||||
|
||||
'@tauri-apps/plugin-os@2.2.1':
|
||||
dependencies:
|
||||
@@ -4876,6 +4910,9 @@ snapshots:
|
||||
|
||||
acorn@8.14.1: {}
|
||||
|
||||
acorn@8.15.0:
|
||||
optional: true
|
||||
|
||||
agent-base@7.1.3: {}
|
||||
|
||||
ahooks@3.8.4(react@18.3.1):
|
||||
@@ -7232,8 +7269,8 @@ snapshots:
|
||||
|
||||
terser@5.40.0:
|
||||
dependencies:
|
||||
'@jridgewell/source-map': 0.3.6
|
||||
acorn: 8.14.1
|
||||
'@jridgewell/source-map': 0.3.11
|
||||
acorn: 8.15.0
|
||||
commander: 2.20.3
|
||||
source-map-support: 0.5.21
|
||||
optional: true
|
||||
|
||||
2896
src-tauri/Cargo.lock
generated
2896
src-tauri/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "coco"
|
||||
version = "0.7.1"
|
||||
version = "0.8.0"
|
||||
description = "Search, connect, collaborate – all in one place."
|
||||
authors = ["INFINI Labs"]
|
||||
edition = "2024"
|
||||
@@ -15,6 +15,7 @@ crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = ["default"] }
|
||||
cfg-if = "1.0.1"
|
||||
|
||||
[features]
|
||||
default = ["desktop"]
|
||||
@@ -61,7 +62,7 @@ tauri-plugin-drag = "2"
|
||||
tauri-plugin-macos-permissions = "2"
|
||||
tauri-plugin-fs-pro = "2"
|
||||
tauri-plugin-screenshots = "2"
|
||||
applications = { git = "https://github.com/infinilabs/applications-rs", rev = "31b0c030a0f3bc82275fe12debe526153978671d" }
|
||||
applications = { git = "https://github.com/infinilabs/applications-rs", rev = "b5fac4034a40d42e72f727f1aa1cc1f19fe86653" }
|
||||
tokio-native-tls = "0.3" # For wss connections
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tokio-tungstenite = { version = "0.20", features = ["native-tls"] }
|
||||
@@ -103,17 +104,42 @@ zip = "4.0.0"
|
||||
url = "2.5.2"
|
||||
camino = "1.1.10"
|
||||
tokio-stream = { version = "0.1.17", features = ["io-util"] }
|
||||
cfg-if = "1.0.1"
|
||||
sysinfo = "0.35.2"
|
||||
indexmap = { version = "2.10.0", features = ["serde"] }
|
||||
strum = { version = "0.27.2", features = ["derive"] }
|
||||
sys-locale = "0.3.2"
|
||||
tauri-plugin-prevent-default = "1"
|
||||
oneshot = "0.1.11"
|
||||
bitflags = "2.9.3"
|
||||
cfg-if = "1.0.1"
|
||||
dunce = "1.0.5"
|
||||
urlencoding = "2.1.3"
|
||||
scraper = "0.17"
|
||||
toml = "0.8"
|
||||
path-clean = "1.0.1"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.23.0"
|
||||
|
||||
[target."cfg(target_os = \"macos\")".dependencies]
|
||||
tauri-nspanel = { git = "https://github.com/ahkohd/tauri-nspanel", branch = "v2" }
|
||||
cocoa = "0.24"
|
||||
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]
|
||||
tauri-plugin-single-instance = { version = "2.0.0", features = ["deep-link"] }
|
||||
serde = { version = "1.0.219", features = ["derive"], optional = true }
|
||||
|
||||
|
||||
[profile.dev]
|
||||
incremental = true # Compile your binary in smaller steps.
|
||||
@@ -134,4 +160,8 @@ semver = { version = "1", features = ["serde"] }
|
||||
|
||||
[target."cfg(target_os = \"windows\")".dependencies]
|
||||
enigo="0.3"
|
||||
windows = { version = "0.61.3", features = ["Win32_Foundation", "Win32_System_Com", "Win32_System_Ole", "Win32_System_Search", "Win32_UI_Shell_PropertiesSystem", "Win32_Data"] }
|
||||
windows = { version = "0.61", features = ["Win32_Foundation", "Win32_System_Com", "Win32_System_Ole", "Win32_System_Search", "Win32_UI_Shell_PropertiesSystem", "Win32_Data"] }
|
||||
windows-sys = { version = "0.61", features = ["Win32", "Win32_System", "Win32_System_Com"] }
|
||||
|
||||
[target."cfg(target_os = \"windows\")".build-dependencies]
|
||||
bindgen = "0.72.1"
|
||||
|
||||
@@ -12,8 +12,6 @@
|
||||
<true/>
|
||||
<key>com.apple.security.automation.apple-events</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.microphone</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.audio-input</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
@@ -26,5 +24,6 @@
|
||||
<string>6GVZT94974.rs.coco.app</string>
|
||||
<key>com.apple.developer.team-identifier</key>
|
||||
<string>6GVZT94974</string>
|
||||
|
||||
</dict>
|
||||
</plist>
|
||||
</plist>
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Request camera access for WebRTC</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>Request microphone access for WebRTC</string>
|
||||
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>rs.coco.app</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
|
||||
@@ -11,4 +11,32 @@ fn main() {
|
||||
//
|
||||
// unexpected condition name: `ci`
|
||||
println!("cargo::rustc-check-cfg=cfg(ci)");
|
||||
|
||||
// Bindgen searchapi.h on Windows as the windows create does not provide
|
||||
// bindings for it
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(target_os = "windows")] {
|
||||
use std::env;
|
||||
use std::path::PathBuf;
|
||||
|
||||
let wrapper_header = r#"#include <windows.h>
|
||||
#include <searchapi.h>"#;
|
||||
|
||||
let searchapi_bindings = bindgen::Builder::default()
|
||||
.header_contents("wrapper.h", wrapper_header)
|
||||
.generate()
|
||||
.expect("failed to generate bindings for <searchapi.h>");
|
||||
|
||||
let out_path = PathBuf::from(env::var("OUT_DIR").unwrap());
|
||||
searchapi_bindings
|
||||
.write_to_file(out_path.join("searchapi_bindings.rs"))
|
||||
.expect("couldn't write bindings to <OUT_DIR/searchapi_bindings.rs>")
|
||||
|
||||
// Looks like there is no need to link the library that contains the
|
||||
// implementation of functions declared in 'searchapi.h' manually as
|
||||
// the FFI bindings work (without doing that).
|
||||
//
|
||||
// This is wield, I do not expect the linker will link it automatically.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,6 +69,7 @@
|
||||
"updater:default",
|
||||
"windows-version:default",
|
||||
"log:default",
|
||||
"opener:default"
|
||||
"opener:default",
|
||||
"core:window:allow-unminimize"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
use std::{fs::create_dir, io::Read};
|
||||
|
||||
use tauri::Manager;
|
||||
use tauri::{AppHandle, Manager};
|
||||
use tauri_plugin_autostart::ManagerExt;
|
||||
|
||||
/// If the state reported from the OS and the state stored by us differ, our state is
|
||||
/// prioritized and seen as the correct one. Update the OS state to make them consistent.
|
||||
pub fn ensure_autostart_state_consistent(app: &mut tauri::App) -> Result<(), String> {
|
||||
let autostart_manager = app.autolaunch();
|
||||
pub fn ensure_autostart_state_consistent(tauri_app_handle: &AppHandle) -> Result<(), String> {
|
||||
let autostart_manager = tauri_app_handle.autolaunch();
|
||||
|
||||
let os_state = autostart_manager.is_enabled().map_err(|e| e.to_string())?;
|
||||
let coco_stored_state = current_autostart(app.app_handle()).map_err(|e| e.to_string())?;
|
||||
let coco_stored_state = current_autostart(tauri_app_handle).map_err(|e| e.to_string())?;
|
||||
|
||||
if os_state != coco_stored_state {
|
||||
log::warn!(
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
#[cfg(target_os = "macos")]
|
||||
use crate::extension::built_in::window_management::actions::Action;
|
||||
use crate::extension::{ExtensionPermission, ExtensionSettings, ViewExtensionUISettings};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value as Json;
|
||||
use std::collections::HashMap;
|
||||
use tauri::AppHandle;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RichLabel {
|
||||
@@ -30,17 +34,48 @@ pub struct EditorInfo {
|
||||
pub timestamp: Option<String>,
|
||||
}
|
||||
|
||||
/// Defines the action that would be performed when a document gets opened.
|
||||
/// Defines the action that would be performed when a [document](Document) gets opened.
|
||||
///
|
||||
/// "Document" is a uniform type that the backend uses to send the search results
|
||||
/// back to the frontend. Since Coco can search many sources, "Document" can
|
||||
/// represent different things, application, web page, local file, extensions, and
|
||||
/// so on. Each has its own specific open action.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub(crate) enum OnOpened {
|
||||
/// Launch the application
|
||||
Application { app_path: String },
|
||||
/// Open the URL.
|
||||
Document { url: String },
|
||||
/// Perform this WM action.
|
||||
#[cfg(target_os = "macos")]
|
||||
WindowManagementAction { action: Action },
|
||||
/// The document is an extension.
|
||||
Extension(ExtensionOnOpened),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub(crate) struct ExtensionOnOpened {
|
||||
/// Different types of extensions have different open behaviors.
|
||||
pub(crate) ty: ExtensionOnOpenedType,
|
||||
/// Extensions settings. Some could affect open action.
|
||||
///
|
||||
/// Optional because not all extensions have their settings.
|
||||
pub(crate) settings: Option<ExtensionSettings>,
|
||||
/// Permission needed by this extension.
|
||||
///
|
||||
/// We do permission check when opening this permission. Currently, we only
|
||||
/// do this to View extensions.
|
||||
pub(crate) permission: Option<ExtensionPermission>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub(crate) enum ExtensionOnOpenedType {
|
||||
/// Spawn a child process to run the `CommandAction`.
|
||||
Command {
|
||||
action: crate::extension::CommandAction,
|
||||
},
|
||||
/// Open the `link`.
|
||||
//
|
||||
// NOTE that this variant has the same definition as `struct Quicklink`, but we
|
||||
// cannot use it directly, its `link` field should be deserialized/serialized
|
||||
// from/to a string, but we need a JSON object here.
|
||||
@@ -50,6 +85,13 @@ pub(crate) enum OnOpened {
|
||||
link: crate::extension::QuicklinkLink,
|
||||
open_with: Option<String>,
|
||||
},
|
||||
View {
|
||||
/// Path to the HTML file that coco will load and render.
|
||||
///
|
||||
/// It should be an absolute path or Tauri cannot open it.
|
||||
page: String,
|
||||
ui: Option<ViewExtensionUISettings>,
|
||||
},
|
||||
}
|
||||
|
||||
impl OnOpened {
|
||||
@@ -57,20 +99,33 @@ impl OnOpened {
|
||||
match self {
|
||||
Self::Application { app_path } => app_path.clone(),
|
||||
Self::Document { url } => url.clone(),
|
||||
Self::Command { action } => {
|
||||
const WHITESPACE: &str = " ";
|
||||
let mut ret = action.exec.clone();
|
||||
ret.push_str(WHITESPACE);
|
||||
if let Some(ref args) = action.args {
|
||||
ret.push_str(args.join(WHITESPACE).as_str());
|
||||
}
|
||||
|
||||
ret
|
||||
#[cfg(target_os = "macos")]
|
||||
Self::WindowManagementAction { action: _ } => {
|
||||
// We don't have URL for this
|
||||
String::from("N/A")
|
||||
}
|
||||
Self::Extension(ext_on_opened) => {
|
||||
match &ext_on_opened.ty {
|
||||
ExtensionOnOpenedType::Command { action } => {
|
||||
const WHITESPACE: &str = " ";
|
||||
let mut ret = action.exec.clone();
|
||||
ret.push_str(WHITESPACE);
|
||||
if let Some(ref args) = action.args {
|
||||
ret.push_str(args.join(WHITESPACE).as_str());
|
||||
}
|
||||
|
||||
ret
|
||||
}
|
||||
// Currently, our URL is static and does not support dynamic parameters.
|
||||
// The URL of a quicklink is nearly useless without such dynamic user
|
||||
// inputs, so until we have dynamic URL support, we just use "N/A".
|
||||
ExtensionOnOpenedType::Quicklink { .. } => String::from("N/A"),
|
||||
ExtensionOnOpenedType::View { page: _, ui: _ } => {
|
||||
// We currently don't have URL for this kind of extension.
|
||||
String::from("N/A")
|
||||
}
|
||||
}
|
||||
}
|
||||
// Currently, our URL is static and does not support dynamic parameters.
|
||||
// The URL of a quicklink is nearly useless without such dynamic user
|
||||
// inputs, so until we have dynamic URL support, we just use "N/A".
|
||||
Self::Quicklink { .. } => String::from("N/A"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -79,7 +134,7 @@ impl OnOpened {
|
||||
pub(crate) async fn open(
|
||||
tauri_app_handle: AppHandle,
|
||||
on_opened: OnOpened,
|
||||
extra_args: Option<HashMap<String, String>>,
|
||||
extra_args: Option<HashMap<String, Json>>,
|
||||
) -> Result<(), String> {
|
||||
use crate::util::open as homemade_tauri_shell_open;
|
||||
use std::process::Command;
|
||||
@@ -95,65 +150,116 @@ pub(crate) async fn open(
|
||||
|
||||
homemade_tauri_shell_open(tauri_app_handle.clone(), url).await?
|
||||
}
|
||||
OnOpened::Command { action } => {
|
||||
log::debug!("open (execute) command [{:?}]", action);
|
||||
#[cfg(target_os = "macos")]
|
||||
OnOpened::WindowManagementAction { action } => {
|
||||
log::debug!("perform Window Management action [{:?}]", action);
|
||||
|
||||
let mut cmd = Command::new(action.exec);
|
||||
if let Some(args) = action.args {
|
||||
cmd.args(args);
|
||||
}
|
||||
let output = cmd.output().map_err(|e| e.to_string())?;
|
||||
// Sometimes, we wanna see the result in logs even though it doesn't fail.
|
||||
log::debug!(
|
||||
"executing open(Command) result, exit code: [{}], stdout: [{}], stderr: [{}]",
|
||||
output.status,
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
if !output.status.success() {
|
||||
log::warn!(
|
||||
"executing open(Command) failed, exit code: [{}], stdout: [{}], stderr: [{}]",
|
||||
output.status,
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
|
||||
return Err(format!(
|
||||
"Command failed, stderr [{}]",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
));
|
||||
}
|
||||
crate::extension::built_in::window_management::perform_action_on_main_thread(
|
||||
&tauri_app_handle,
|
||||
action,
|
||||
)?;
|
||||
}
|
||||
OnOpened::Quicklink {
|
||||
link,
|
||||
open_with: opt_open_with,
|
||||
} => {
|
||||
let url = link.concatenate_url(&extra_args);
|
||||
|
||||
log::debug!("open quicklink [{}] with [{:?}]", url, opt_open_with);
|
||||
|
||||
cfg_if::cfg_if! {
|
||||
// The `open_with` functionality is only supported on macOS, provided
|
||||
// by the `open -a` command.
|
||||
if #[cfg(target_os = "macos")] {
|
||||
let mut cmd = Command::new("open");
|
||||
if let Some(ref open_with) = opt_open_with {
|
||||
cmd.arg("-a");
|
||||
cmd.arg(open_with.as_str());
|
||||
OnOpened::Extension(ext_on_opened) => {
|
||||
// Apply the settings that would affect open behavior
|
||||
if let Some(settings) = ext_on_opened.settings {
|
||||
if let Some(should_hide) = settings.hide_before_open {
|
||||
if should_hide {
|
||||
crate::hide_coco(tauri_app_handle.clone()).await;
|
||||
}
|
||||
cmd.arg(&url);
|
||||
}
|
||||
}
|
||||
let permission = ext_on_opened.permission;
|
||||
|
||||
let output = cmd.output().map_err(|e| format!("failed to spawn [open] due to error [{}]", e))?;
|
||||
match ext_on_opened.ty {
|
||||
ExtensionOnOpenedType::Command { action } => {
|
||||
log::debug!("open (execute) command [{:?}]", action);
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(format!(
|
||||
"failed to open with app {:?}: {}",
|
||||
opt_open_with,
|
||||
let mut cmd = Command::new(action.exec);
|
||||
if let Some(args) = action.args {
|
||||
cmd.args(args);
|
||||
}
|
||||
let output = cmd.output().map_err(|e| e.to_string())?;
|
||||
// Sometimes, we wanna see the result in logs even though it doesn't fail.
|
||||
log::debug!(
|
||||
"executing open(Command) result, exit code: [{}], stdout: [{}], stderr: [{}]",
|
||||
output.status,
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
));
|
||||
);
|
||||
if !output.status.success() {
|
||||
log::warn!(
|
||||
"executing open(Command) failed, exit code: [{}], stdout: [{}], stderr: [{}]",
|
||||
output.status,
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
|
||||
return Err(format!(
|
||||
"Command failed, stderr [{}]",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
));
|
||||
}
|
||||
} else {
|
||||
homemade_tauri_shell_open(tauri_app_handle.clone(), url).await?
|
||||
}
|
||||
ExtensionOnOpenedType::Quicklink {
|
||||
link,
|
||||
open_with: opt_open_with,
|
||||
} => {
|
||||
let url = link.concatenate_url(&extra_args);
|
||||
|
||||
log::debug!("open quicklink [{}] with [{:?}]", url, opt_open_with);
|
||||
|
||||
cfg_if::cfg_if! {
|
||||
// The `open_with` functionality is only supported on macOS, provided
|
||||
// by the `open -a` command.
|
||||
if #[cfg(target_os = "macos")] {
|
||||
let mut cmd = Command::new("open");
|
||||
if let Some(ref open_with) = opt_open_with {
|
||||
cmd.arg("-a");
|
||||
cmd.arg(open_with.as_str());
|
||||
}
|
||||
cmd.arg(&url);
|
||||
|
||||
let output = cmd.output().map_err(|e| format!("failed to spawn [open] due to error [{}]", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(format!(
|
||||
"failed to open with app {:?}: {}",
|
||||
opt_open_with,
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
));
|
||||
}
|
||||
} else {
|
||||
homemade_tauri_shell_open(tauri_app_handle.clone(), url).await?
|
||||
}
|
||||
}
|
||||
}
|
||||
ExtensionOnOpenedType::View { page, ui } => {
|
||||
/*
|
||||
* Emit an event to let the frontend code open this extension.
|
||||
*
|
||||
* Payload `page_and_permission` contains the information needed
|
||||
* to do that.
|
||||
*
|
||||
* See "src/pages/main/index.tsx" for more info.
|
||||
*/
|
||||
use serde_json::Value as Json;
|
||||
use serde_json::to_value;
|
||||
|
||||
let mut extra_args =
|
||||
extra_args.expect("extra_args is needed to open() a view extension");
|
||||
let document = extra_args.remove("document").expect(
|
||||
"extra argument [document] should be provided to open a view extension",
|
||||
);
|
||||
|
||||
let page_and_permission: [Json; 4] = [
|
||||
Json::String(page),
|
||||
to_value(permission).unwrap(),
|
||||
to_value(ui).unwrap(),
|
||||
document,
|
||||
];
|
||||
tauri_app_handle
|
||||
.emit("open_view_extension", page_and_permission)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
5
src-tauri/src/extension/api/apis.toml
Normal file
5
src-tauri/src/extension/api/apis.toml
Normal file
@@ -0,0 +1,5 @@
|
||||
# Complete Coco extension API list grouped by its category.
|
||||
|
||||
fs = [
|
||||
"read_dir"
|
||||
]
|
||||
22
src-tauri/src/extension/api/fs.rs
Normal file
22
src-tauri/src/extension/api/fs.rs
Normal file
@@ -0,0 +1,22 @@
|
||||
//! File system APIs
|
||||
|
||||
use tokio::fs::read_dir as tokio_read_dir;
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn read_dir(path: String) -> Result<Vec<String>, String> {
|
||||
let mut iter = tokio_read_dir(path).await.map_err(|e| e.to_string())?;
|
||||
|
||||
let mut file_names = Vec::new();
|
||||
|
||||
loop {
|
||||
let opt_entry = iter.next_entry().await.map_err(|e| e.to_string())?;
|
||||
let Some(entry) = opt_entry else {
|
||||
break;
|
||||
};
|
||||
|
||||
let file_name = entry.file_name().to_string_lossy().into_owned();
|
||||
file_names.push(file_name);
|
||||
}
|
||||
|
||||
Ok(file_names)
|
||||
}
|
||||
21
src-tauri/src/extension/api/mod.rs
Normal file
21
src-tauri/src/extension/api/mod.rs
Normal file
@@ -0,0 +1,21 @@
|
||||
//! The Rust implementation of the Coco extension APIs.
|
||||
//!
|
||||
//! Extension developers do not use these Rust APIs directly, they use our
|
||||
//! [Typescript library][ts_lib], which eventually calls these APIs.
|
||||
//!
|
||||
//! [ts_lib]: https://github.com/infinilabs/coco-api
|
||||
|
||||
pub(crate) mod fs;
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Return all the available APIs grouped by their category.
|
||||
#[tauri::command]
|
||||
pub(crate) fn apis() -> HashMap<String, Vec<String>> {
|
||||
static APIS_TOML: &str = include_str!("./apis.toml");
|
||||
|
||||
let apis: HashMap<String, Vec<String>> =
|
||||
toml::from_str(APIS_TOML).expect("Failed to parse apis.toml file");
|
||||
|
||||
apis
|
||||
}
|
||||
@@ -28,7 +28,7 @@ use serde_json::Value as Json;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use tauri::{AppHandle, Manager, async_runtime};
|
||||
use tauri_plugin_fs_pro::{IconOptions, icon, metadata, name};
|
||||
use tauri_plugin_fs_pro::{IconOptions, icon, metadata};
|
||||
use tauri_plugin_global_shortcut::GlobalShortcutExt;
|
||||
use tauri_plugin_global_shortcut::Shortcut;
|
||||
use tauri_plugin_global_shortcut::ShortcutEvent;
|
||||
@@ -36,7 +36,13 @@ use tauri_plugin_global_shortcut::ShortcutState;
|
||||
use tauri_plugin_store::StoreExt;
|
||||
use tokio::sync::oneshot::Sender as OneshotSender;
|
||||
|
||||
// Deprecated. We no longer index this field, but to be backward-compatible, we
|
||||
// have to keep it.
|
||||
const FIELD_APP_NAME: &str = "app_name";
|
||||
|
||||
const FIELD_APP_NAME_IN_SYSTEM_LANG: &str = "app_name_in_system_lang";
|
||||
const FIELD_APP_NAME_ZH: &str = "app_name_zh";
|
||||
const FIELD_APP_NAME_EN: &str = "app_name_en";
|
||||
const FIELD_ICON_PATH: &str = "icon_path";
|
||||
const FIELD_APP_ALIAS: &str = "app_alias";
|
||||
const APPLICATION_SEARCH_SOURCE_ID: &str = "application";
|
||||
@@ -58,37 +64,18 @@ const INDEX_DIR: &str = "local_application_index";
|
||||
pub(crate) const QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME: &str = "Applications";
|
||||
|
||||
pub fn get_default_search_paths() -> Vec<String> {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let home_dir =
|
||||
PathBuf::from(std::env::var_os("HOME").expect("environment variable $HOME not found"));
|
||||
return vec![
|
||||
"/Applications".into(),
|
||||
"/System/Applications".into(),
|
||||
"/System/Library/CoreServices".into(),
|
||||
home_dir
|
||||
.join("Applications")
|
||||
.into_os_string()
|
||||
.into_string()
|
||||
.expect("this path should be UTF-8 encoded"),
|
||||
];
|
||||
let paths = applications::get_default_search_paths();
|
||||
let mut ret = Vec::with_capacity(paths.len());
|
||||
for search_path in paths {
|
||||
let path_string = search_path
|
||||
.into_os_string()
|
||||
.into_string()
|
||||
.expect("path should be UTF-8 encoded");
|
||||
|
||||
ret.push(path_string);
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
let paths = applications::get_default_search_paths();
|
||||
let mut ret = Vec::with_capacity(paths.len());
|
||||
for search_path in paths {
|
||||
let path_string = search_path
|
||||
.into_os_string()
|
||||
.into_string()
|
||||
.expect("path should be UTF-8 encoded");
|
||||
|
||||
ret.push(path_string);
|
||||
}
|
||||
|
||||
ret
|
||||
}
|
||||
ret
|
||||
}
|
||||
|
||||
/// Helper function to return `app`'s path.
|
||||
@@ -115,16 +102,56 @@ fn get_app_path(app: &App) -> String {
|
||||
.expect("should be UTF-8 encoded")
|
||||
}
|
||||
|
||||
/// Helper function to return `app`'s path.
|
||||
///
|
||||
/// * macOS: extract `app_path`'s file name and remove the file extension
|
||||
/// * Windows/Linux: return the name specified in `.desktop` file
|
||||
async fn get_app_name(app: &App) -> String {
|
||||
if cfg!(any(target_os = "linux", target_os = "windows")) {
|
||||
app.name.clone()
|
||||
/// Helper function to return `app`'s Chinese name.
|
||||
async fn get_app_name_zh(app: &App) -> String {
|
||||
// zh_CN or zh-CN
|
||||
if let Some(name) = app.localized_app_names.get("zh_CN") {
|
||||
return name.clone();
|
||||
}
|
||||
if let Some(name) = app.localized_app_names.get("zh-CN") {
|
||||
return name.clone();
|
||||
}
|
||||
|
||||
// zh_Hans or zh-Hans
|
||||
if let Some(name) = app.localized_app_names.get("zh_Hans") {
|
||||
return name.clone();
|
||||
}
|
||||
if let Some(name) = app.localized_app_names.get("zh-Hans") {
|
||||
return name.clone();
|
||||
}
|
||||
|
||||
// Fall back to base name
|
||||
app.name.clone()
|
||||
}
|
||||
|
||||
/// Helper function to return `app`'s English name.
|
||||
async fn get_app_name_en(app: &App) -> String {
|
||||
// en_US or en-US
|
||||
if let Some(name) = app.localized_app_names.get("en_US") {
|
||||
return name.clone();
|
||||
}
|
||||
if let Some(name) = app.localized_app_names.get("en-US") {
|
||||
return name.clone();
|
||||
}
|
||||
|
||||
// English (General)
|
||||
if let Some(name) = app.localized_app_names.get("en") {
|
||||
return name.clone();
|
||||
}
|
||||
|
||||
// Fall back to base name
|
||||
app.name.clone()
|
||||
}
|
||||
|
||||
/// Helper function to return `app`'s name in system language.
|
||||
async fn get_app_name_in_system_lang(app: &App) -> String {
|
||||
let system_lang = crate::util::system_lang::get_system_lang();
|
||||
|
||||
if let Some(name) = app.localized_app_names.get(&system_lang) {
|
||||
name.clone()
|
||||
} else {
|
||||
let app_path = get_app_path(app);
|
||||
name(app_path.into()).await
|
||||
// Fall back to base name
|
||||
app.name.clone()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,9 +248,17 @@ async fn index_applications_if_not_indexed(
|
||||
pizza_engine_builder.set_data_store(disk_store);
|
||||
|
||||
let mut schema = Schema::new();
|
||||
let field_app_name = Property::builder(FieldType::Text).build();
|
||||
let field_app_name_zh = Property::builder(FieldType::Text).build();
|
||||
schema
|
||||
.add_property(FIELD_APP_NAME, field_app_name)
|
||||
.add_property(FIELD_APP_NAME_ZH, field_app_name_zh)
|
||||
.expect("no collision could happen");
|
||||
let field_app_name_en = Property::builder(FieldType::Text).build();
|
||||
schema
|
||||
.add_property(FIELD_APP_NAME_EN, field_app_name_en)
|
||||
.expect("no collision could happen");
|
||||
let field_app_name_in_system_lang = Property::builder(FieldType::Text).build();
|
||||
schema
|
||||
.add_property(FIELD_APP_NAME_IN_SYSTEM_LANG, field_app_name_in_system_lang)
|
||||
.expect("no collision could happen");
|
||||
let property_icon = Property::builder(FieldType::Text).index(false).build();
|
||||
schema
|
||||
@@ -268,21 +303,39 @@ async fn index_applications_if_not_indexed(
|
||||
|
||||
for app in apps.iter() {
|
||||
let app_path = get_app_path(app);
|
||||
let app_name = get_app_name(app).await;
|
||||
let app_name_zh = get_app_name_zh(app).await;
|
||||
let app_name_en = get_app_name_en(app).await;
|
||||
let app_name_in_system_lang = get_app_name_in_system_lang(app).await;
|
||||
let app_icon_path = get_app_icon_path(&tauri_app_handle, app)
|
||||
.await
|
||||
.map_err(|str| anyhow::anyhow!(str))?;
|
||||
let app_alias = get_app_alias(&tauri_app_handle, &app_path).unwrap_or(String::new());
|
||||
|
||||
if app_name.is_empty() || app_name.eq(&tauri_app_handle.package_info().name) {
|
||||
// Skip if all names are empty
|
||||
if app_name_zh.is_empty()
|
||||
&& app_name_en.is_empty()
|
||||
&& app_name_in_system_lang.is_empty()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if this is Coco itself
|
||||
//
|
||||
// Coco does not have localized app names, so app_name_en and app_name_zh
|
||||
// should both have value "Coco-AI", so either should work.
|
||||
if app_name_en == tauri_app_handle.package_info().name {
|
||||
continue;
|
||||
}
|
||||
|
||||
// You cannot write `app_name.clone()` within the `doc!()` macro, we should fix this.
|
||||
let app_name_clone = app_name.clone();
|
||||
let app_name_zh_clone = app_name_zh.clone();
|
||||
let app_name_en_clone = app_name_en.clone();
|
||||
let app_name_in_system_lang = app_name_in_system_lang.clone();
|
||||
let app_path_clone = app_path.clone();
|
||||
let document = doc!( app_path_clone, {
|
||||
FIELD_APP_NAME => app_name_clone,
|
||||
FIELD_APP_NAME_ZH => app_name_zh_clone,
|
||||
FIELD_APP_NAME_EN => app_name_en_clone,
|
||||
FIELD_APP_NAME_IN_SYSTEM_LANG => app_name_in_system_lang,
|
||||
FIELD_ICON_PATH => app_icon_path,
|
||||
FIELD_APP_ALIAS => app_alias,
|
||||
}
|
||||
@@ -291,8 +344,8 @@ async fn index_applications_if_not_indexed(
|
||||
// We don't error out because one failure won't break the whole thing
|
||||
if let Err(e) = writer.create_document(document).await {
|
||||
warn!(
|
||||
"failed to index application [app name: '{}', app path: '{}'] due to error [{}]",
|
||||
app_name, app_path, e
|
||||
"failed to index application [app name zh: '{}', app name en: '{}', app path: '{}'] due to error [{}]",
|
||||
app_name_zh, app_name_en, app_path, e
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -421,9 +474,19 @@ impl Task for SearchApplicationsTask {
|
||||
//
|
||||
// It will be passed to Pizza like "Google\nChrome". Using Display impl would result
|
||||
// in an invalid query DSL and serde will complain.
|
||||
//
|
||||
// In order to be backward compatible, we still do match and prefix queries to the
|
||||
// app_name field.
|
||||
let dsl = format!(
|
||||
"{{ \"query\": {{ \"bool\": {{ \"should\": [ {{ \"match\": {{ \"{FIELD_APP_NAME}\": {:?} }} }}, {{ \"prefix\": {{ \"{FIELD_APP_NAME}\": {:?} }} }} ] }} }} }}",
|
||||
self.query_string, self.query_string
|
||||
"{{ \"query\": {{ \"bool\": {{ \"should\": [ {{ \"match\": {{ \"{FIELD_APP_NAME_ZH}\": {:?} }} }}, {{ \"prefix\": {{ \"{FIELD_APP_NAME_ZH}\": {:?} }} }}, {{ \"match\": {{ \"{FIELD_APP_NAME_EN}\": {:?} }} }}, {{ \"prefix\": {{ \"{FIELD_APP_NAME_EN}\": {:?} }} }}, {{ \"match\": {{ \"{FIELD_APP_NAME_IN_SYSTEM_LANG}\": {:?} }} }}, {{ \"prefix\": {{ \"{FIELD_APP_NAME_IN_SYSTEM_LANG}\": {:?} }} }}, {{ \"match\": {{ \"{FIELD_APP_NAME}\": {:?} }} }}, {{ \"prefix\": {{ \"{FIELD_APP_NAME}\": {:?} }} }} ] }} }} }}",
|
||||
self.query_string,
|
||||
self.query_string,
|
||||
self.query_string,
|
||||
self.query_string,
|
||||
self.query_string,
|
||||
self.query_string,
|
||||
self.query_string,
|
||||
self.query_string
|
||||
);
|
||||
|
||||
let state = state
|
||||
@@ -620,7 +683,7 @@ impl SearchSource for ApplicationSearchSource {
|
||||
|
||||
let total_hits = search_result.total_hits;
|
||||
let source = self.get_type();
|
||||
let hits = pizza_engine_hits_to_coco_hits(search_result.hits);
|
||||
let hits = pizza_engine_hits_to_coco_hits(search_result.hits).await;
|
||||
|
||||
Ok(QueryResponse {
|
||||
source,
|
||||
@@ -630,9 +693,11 @@ impl SearchSource for ApplicationSearchSource {
|
||||
}
|
||||
}
|
||||
|
||||
fn pizza_engine_hits_to_coco_hits(
|
||||
async fn pizza_engine_hits_to_coco_hits(
|
||||
pizza_engine_hits: Option<Vec<PizzaEngineDocument>>,
|
||||
) -> Vec<(Document, f64)> {
|
||||
use crate::util::app_lang::{Lang, get_app_lang};
|
||||
|
||||
let Some(engine_hits) = pizza_engine_hits else {
|
||||
return Vec::new();
|
||||
};
|
||||
@@ -641,10 +706,43 @@ fn pizza_engine_hits_to_coco_hits(
|
||||
for engine_hit in engine_hits {
|
||||
let score = engine_hit.score.unwrap_or(0.0) as f64;
|
||||
let mut document_fields = engine_hit.fields;
|
||||
let app_name = match document_fields.remove(FIELD_APP_NAME).unwrap() {
|
||||
FieldValue::Text(string) => string,
|
||||
_ => unreachable!("field name is of type Text"),
|
||||
|
||||
// Get both Chinese and English names
|
||||
let opt_app_name_zh = match document_fields.remove(FIELD_APP_NAME_ZH) {
|
||||
Some(FieldValue::Text(string)) => Some(string),
|
||||
_ => None,
|
||||
};
|
||||
let opt_app_name_en = match document_fields.remove(FIELD_APP_NAME_EN) {
|
||||
Some(FieldValue::Text(string)) => Some(string),
|
||||
_ => None,
|
||||
};
|
||||
let opt_app_name_deprecated = match document_fields.remove(FIELD_APP_NAME) {
|
||||
Some(FieldValue::Text(string)) => Some(string),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let app_name: String = {
|
||||
if let Some(legacy_app_name) = opt_app_name_deprecated {
|
||||
// Old version of index, which only contains the field app_name.
|
||||
legacy_app_name
|
||||
} else {
|
||||
// New version of index store the following 2 fields
|
||||
|
||||
let panic_msg = format!(
|
||||
"new version of index should contain field [{}] and [{}]",
|
||||
FIELD_APP_NAME_EN, FIELD_APP_NAME_ZH
|
||||
);
|
||||
let app_name_zh = opt_app_name_zh.expect(&panic_msg);
|
||||
let app_name_en = opt_app_name_en.expect(&panic_msg);
|
||||
|
||||
// Choose the appropriate name based on current language
|
||||
match get_app_lang().await {
|
||||
Lang::zh_CN => app_name_zh,
|
||||
Lang::en_US => app_name_en,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let app_path = engine_hit.key.expect("key should be set to app path");
|
||||
let app_icon_path = match document_fields.remove(FIELD_ICON_PATH).unwrap() {
|
||||
FieldValue::Text(string) => string,
|
||||
@@ -664,7 +762,7 @@ fn pizza_engine_hits_to_coco_hits(
|
||||
}),
|
||||
id: app_path.clone(),
|
||||
category: Some("Application".to_string()),
|
||||
title: Some(app_name.clone()),
|
||||
title: Some(app_name),
|
||||
icon: Some(app_icon_path),
|
||||
on_opened: Some(on_opened),
|
||||
url: Some(url),
|
||||
@@ -1052,15 +1150,24 @@ pub async fn get_app_search_path(tauri_app_handle: AppHandle) -> Vec<String> {
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_app_list(tauri_app_handle: AppHandle) -> Result<Vec<Extension>, String> {
|
||||
use crate::util::app_lang::{Lang, get_app_lang};
|
||||
|
||||
let search_paths = get_app_search_path(tauri_app_handle.clone()).await;
|
||||
let apps = list_app_in(search_paths)?;
|
||||
|
||||
let mut app_entries = Vec::with_capacity(apps.len());
|
||||
let lang = get_app_lang().await;
|
||||
|
||||
for app in apps {
|
||||
let name = get_app_name(&app).await;
|
||||
let name = match lang {
|
||||
Lang::zh_CN => get_app_name_zh(&app).await,
|
||||
Lang::en_US => get_app_name_en(&app).await,
|
||||
};
|
||||
|
||||
// filter out Coco-AI
|
||||
//
|
||||
// Coco does not have localized app names, so regardless the chosen language, name
|
||||
// should have value "Coco-AI".
|
||||
if name.eq(&tauri_app_handle.package_info().name) {
|
||||
continue;
|
||||
}
|
||||
@@ -1128,11 +1235,15 @@ pub async fn get_app_list(tauri_app_handle: AppHandle) -> Result<Vec<Extension>,
|
||||
quicklink: None,
|
||||
commands: None,
|
||||
scripts: None,
|
||||
views: None,
|
||||
quicklinks: None,
|
||||
alias: Some(alias),
|
||||
hotkey,
|
||||
enabled,
|
||||
settings: None,
|
||||
page: None,
|
||||
ui: None,
|
||||
permission: None,
|
||||
screenshots: None,
|
||||
url: None,
|
||||
version: None,
|
||||
|
||||
@@ -138,7 +138,7 @@ impl SearchSource for CalculatorSource {
|
||||
// will only be evaluated against non-whitespace characters.
|
||||
let query_string = query_string.trim();
|
||||
|
||||
if query_string.is_empty() || query_string.len() == 1 {
|
||||
if query_string.is_empty() {
|
||||
return Ok(QueryResponse {
|
||||
source: self.get_type(),
|
||||
hits: Vec::new(),
|
||||
@@ -150,6 +150,26 @@ impl SearchSource for CalculatorSource {
|
||||
let query_source = self.get_type();
|
||||
let base_score = self.base_score;
|
||||
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);
|
||||
|
||||
match res_num {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! File Search configuration entries definition and getter/setter functions.
|
||||
|
||||
use crate::extension::built_in::file_search::implementation::apply_config;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
@@ -23,7 +24,7 @@ static HOME_DIR: LazyLock<String> = LazyLock::new(|| {
|
||||
.expect("User home directory should be encoded with UTF-8")
|
||||
});
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Copy)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Copy, PartialEq)]
|
||||
pub enum SearchBy {
|
||||
Name,
|
||||
NameAndContents,
|
||||
@@ -197,13 +198,19 @@ pub async fn set_file_system_config(
|
||||
.store(TAURI_STORE_FILE_SYSTEM_CONFIG)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
store.set(TAURI_STORE_KEY_SEARCH_PATHS, config.search_paths);
|
||||
store.set(TAURI_STORE_KEY_EXCLUDE_PATHS, config.exclude_paths);
|
||||
store.set(TAURI_STORE_KEY_FILE_TYPES, config.file_types);
|
||||
store.set(TAURI_STORE_KEY_SEARCH_PATHS, config.search_paths.as_slice());
|
||||
store.set(
|
||||
TAURI_STORE_KEY_EXCLUDE_PATHS,
|
||||
config.exclude_paths.as_slice(),
|
||||
);
|
||||
store.set(TAURI_STORE_KEY_FILE_TYPES, config.file_types.as_slice());
|
||||
store.set(
|
||||
TAURI_STORE_KEY_SEARCH_BY,
|
||||
serde_json::to_value(config.search_by).unwrap(),
|
||||
);
|
||||
|
||||
// Apply the config when we know that this set operation won't fail
|
||||
apply_config(&config)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
//! File system powered by GNOME's Tracker engine.
|
||||
|
||||
use super::super::super::EXTENSION_ID;
|
||||
use super::super::super::config::FileSearchConfig;
|
||||
use super::super::should_be_filtered_out;
|
||||
use crate::common::document::DataSourceReference;
|
||||
use crate::extension::LOCAL_QUERY_SOURCE_TYPE;
|
||||
use crate::util::file::sync_get_file_icon;
|
||||
use crate::{
|
||||
common::document::{Document, OnOpened},
|
||||
extension::built_in::file_search::config::SearchBy,
|
||||
};
|
||||
use camino::Utf8Path;
|
||||
use gio::Cancellable;
|
||||
use gio::Settings;
|
||||
use gio::prelude::SettingsExtManual;
|
||||
use glib::GString;
|
||||
use glib::collections::strv::StrV;
|
||||
use tracker::{SparqlConnection, SparqlCursor, prelude::SparqlCursorExtManual};
|
||||
|
||||
/// The service that we will connect to.
|
||||
const SERVICE_NAME: &str = "org.freedesktop.Tracker3.Miner.Files";
|
||||
|
||||
/// Tracker won't return scores when we are not using full-text seach. In that
|
||||
/// case, we use this score.
|
||||
const SCORE: f64 = 1.0;
|
||||
|
||||
/// Helper function to return different SPARQL queries depending on the different configurations.
|
||||
fn query_sparql(query_string: &str, config: &FileSearchConfig) -> String {
|
||||
match config.search_by {
|
||||
SearchBy::Name => {
|
||||
// Cannot use the inverted index as that searches for all the attributes,
|
||||
// but we only want to search the filename.
|
||||
format!(
|
||||
"SELECT nie:url(?file_item) WHERE {{ ?file_item nfo:fileName ?fileName . FILTER(regex(?fileName, '{query_string}', 'i')) }}"
|
||||
)
|
||||
}
|
||||
SearchBy::NameAndContents => {
|
||||
// Full-text search against all attributes
|
||||
// OR
|
||||
// filename search
|
||||
format!(
|
||||
"SELECT nie:url(?file_item) fts:rank(?file_item) WHERE {{ {{ ?file_item fts:match '{query_string}' }} UNION {{ ?file_item nfo:fileName ?fileName . FILTER(regex(?fileName, '{query_string}', 'i')) }} }} ORDER BY DESC fts:rank(?file_item)"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper function to replace unsupported characters with whitespace.
|
||||
///
|
||||
/// Tracker will error out if it encounters these characters.
|
||||
///
|
||||
/// The complete list of unsupported characters is unknown and we don't know how
|
||||
/// to escape them, so let's replace them.
|
||||
fn query_string_cleanup(old: &str) -> String {
|
||||
const UNSUPPORTED_CHAR: [char; 3] = ['\'', '\n', '\\'];
|
||||
|
||||
// Using len in bytes is ok
|
||||
let mut chars = Vec::with_capacity(old.len());
|
||||
for char in old.chars() {
|
||||
if UNSUPPORTED_CHAR.contains(&char) {
|
||||
chars.push(' ');
|
||||
} else {
|
||||
chars.push(char);
|
||||
}
|
||||
}
|
||||
|
||||
chars.into_iter().collect()
|
||||
}
|
||||
|
||||
struct Query {
|
||||
conn: SparqlConnection,
|
||||
cursor: SparqlCursor,
|
||||
}
|
||||
|
||||
impl Query {
|
||||
fn new(query_string: &str, config: &FileSearchConfig) -> Result<Self, String> {
|
||||
let query_string = query_string_cleanup(query_string);
|
||||
let sparql = query_sparql(&query_string, config);
|
||||
let conn =
|
||||
SparqlConnection::bus_new(SERVICE_NAME, None, None).map_err(|e| e.to_string())?;
|
||||
let cursor = conn
|
||||
.query(&sparql, Cancellable::NONE)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(Self { conn, cursor })
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Query {
|
||||
fn drop(&mut self) {
|
||||
self.cursor.close();
|
||||
self.conn.close();
|
||||
}
|
||||
}
|
||||
|
||||
impl Iterator for Query {
|
||||
/// It yields a tuple `(file path, score)`
|
||||
type Item = Result<(String, f64), String>;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
loop {
|
||||
let has_next = match self
|
||||
.cursor
|
||||
.next(Cancellable::NONE)
|
||||
.map_err(|e| e.to_string())
|
||||
{
|
||||
Ok(has_next) => has_next,
|
||||
Err(err_str) => return Some(Err(err_str)),
|
||||
};
|
||||
|
||||
if !has_next {
|
||||
return None;
|
||||
}
|
||||
|
||||
// The first column is the URL
|
||||
let file_url_column = self.cursor.string(0);
|
||||
// It could be None (or NULL ptr if you use C), I have no clue why.
|
||||
let opt_str = file_url_column.as_ref().map(|gstr| gstr.as_str());
|
||||
|
||||
match opt_str {
|
||||
Some(url) => {
|
||||
// The returned URL has a prefix that we need to trim
|
||||
const PREFIX: &str = "file://";
|
||||
const PREFIX_LEN: usize = PREFIX.len();
|
||||
|
||||
let file_path = url[PREFIX_LEN..].to_string();
|
||||
assert!(!file_path.is_empty());
|
||||
assert_ne!(file_path, "/", "file search should not hit the root path");
|
||||
|
||||
let score = {
|
||||
// The second column is the score, this column may not
|
||||
// exist. We use SCORE if the real value is absent.
|
||||
let score_column = self.cursor.string(1);
|
||||
let opt_score_str = score_column.as_ref().map(|g_str| g_str.as_str());
|
||||
let opt_score = opt_score_str.map(|str| {
|
||||
str.parse::<f64>()
|
||||
.expect("score should be valid for type f64")
|
||||
});
|
||||
|
||||
opt_score.unwrap_or(SCORE)
|
||||
};
|
||||
|
||||
return Some(Ok((file_path, score)));
|
||||
}
|
||||
None => {
|
||||
// another try
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn hits(
|
||||
query_string: &str,
|
||||
from: usize,
|
||||
size: usize,
|
||||
config: &FileSearchConfig,
|
||||
) -> Result<Vec<(Document, f64)>, String> {
|
||||
// Special cases that will make querying faster.
|
||||
if query_string.is_empty() || size == 0 || config.search_paths.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut result_hits = Vec::with_capacity(size);
|
||||
|
||||
let need_to_skip = {
|
||||
if matches!(config.search_by, SearchBy::Name) {
|
||||
// We don't use full-text search in this case, the returned documents
|
||||
// won't be scored, the query hits won't be sorted, so processing the
|
||||
// from parameter is meaningless.
|
||||
false
|
||||
} else {
|
||||
from > 0
|
||||
}
|
||||
};
|
||||
let mut num_skipped = 0;
|
||||
let should_skip = from;
|
||||
|
||||
let query = Query::new(query_string, config)?;
|
||||
for res_entry in query {
|
||||
let (file_path, score) = res_entry?;
|
||||
|
||||
// This should be called before processing the `from` parameter.
|
||||
if should_be_filtered_out(config, &file_path, true, true, true) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Process the `from` parameter.
|
||||
if need_to_skip && num_skipped < should_skip {
|
||||
// Skip this
|
||||
num_skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
let icon = sync_get_file_icon(&file_path);
|
||||
let file_path_of_type_path = camino::Utf8Path::new(&file_path);
|
||||
let r#where = file_path_of_type_path
|
||||
.parent()
|
||||
.unwrap_or_else(|| {
|
||||
panic!(
|
||||
"expect path [{}] to have a parent, but it does not",
|
||||
file_path
|
||||
);
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let file_name = file_path_of_type_path.file_name().unwrap_or_else(|| {
|
||||
panic!(
|
||||
"expect path [{}] to have a file name, but it does not",
|
||||
file_path
|
||||
);
|
||||
});
|
||||
let on_opened = OnOpened::Document {
|
||||
url: file_path.to_string(),
|
||||
};
|
||||
|
||||
let doc = Document {
|
||||
id: file_path.to_string(),
|
||||
title: Some(file_name.to_string()),
|
||||
source: Some(DataSourceReference {
|
||||
r#type: Some(LOCAL_QUERY_SOURCE_TYPE.into()),
|
||||
name: Some(EXTENSION_ID.into()),
|
||||
id: Some(EXTENSION_ID.into()),
|
||||
icon: Some(String::from("font_Filesearch")),
|
||||
}),
|
||||
category: Some(r#where),
|
||||
on_opened: Some(on_opened),
|
||||
url: Some(file_path),
|
||||
icon: Some(icon.to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
result_hits.push((doc, score));
|
||||
|
||||
// Collected enough documents, return
|
||||
if result_hits.len() >= size {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result_hits)
|
||||
}
|
||||
|
||||
fn ensure_path_in_recursive_indexing_scope(list: &mut StrV, path: &str) {
|
||||
for item in list.iter() {
|
||||
let item_path = Utf8Path::new(item.as_str());
|
||||
let path = Utf8Path::new(path);
|
||||
|
||||
// It is already covered or listed
|
||||
if path.starts_with(item_path) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
list.push(
|
||||
GString::from_utf8_checked(path.as_bytes().to_vec())
|
||||
.expect("search_path_str contains an interior NUL"),
|
||||
);
|
||||
}
|
||||
|
||||
fn ensure_path_and_descendants_not_in_single_indexing_scope(list: &mut StrV, path: &str) {
|
||||
// Indexes to the items that should be removed
|
||||
let mut item_to_remove = Vec::new();
|
||||
for (idx, item) in list.iter().enumerate() {
|
||||
let item_path = Utf8Path::new(item.as_str());
|
||||
let path = Utf8Path::new(path);
|
||||
|
||||
if item_path.starts_with(path) {
|
||||
item_to_remove.push(idx);
|
||||
}
|
||||
}
|
||||
|
||||
// Reverse the indexes so that the remove operation won't invalidate them.
|
||||
for idx in item_to_remove.into_iter().rev() {
|
||||
list.remove(idx);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn apply_config(config: &FileSearchConfig) -> Result<(), String> {
|
||||
// Tracker provides the following configuration entries to allow users to
|
||||
// tweak the indexing scope:
|
||||
//
|
||||
// 1. ignored-directories: A list of names, directories with such names will be ignored.
|
||||
// ['po', 'CVS', 'core-dumps', 'lost+found']
|
||||
// 2. ignored-directories-with-content: Avoid any directory containing a file blocklisted here
|
||||
// ['.trackerignore', '.git', '.hg', '.nomedia']
|
||||
// 3. ignored-files: List of file patterns to avoid
|
||||
// ['*~', '*.o', '*.la', '*.lo', '*.loT', '*.in', '*.m4', '*.rej', ...]
|
||||
// 4. index-recursive-directories: List of directories to index recursively
|
||||
// ['&DESKTOP', '&DOCUMENTS', '&MUSIC', '&PICTURES', '&VIDEOS']
|
||||
// 5. index-single-directories: List of directories to index without inspecting subfolders,
|
||||
// ['$HOME', '&DOWNLOAD']
|
||||
//
|
||||
// The first 3 entries specify patterns, in order to use them, we have to walk
|
||||
// through the whole directory tree listed in search paths, which is impractical.
|
||||
// So we only use the last 2 entries.
|
||||
//
|
||||
//
|
||||
// Just want to mention that setting search path to "/home" could break Tracker:
|
||||
//
|
||||
// ```text
|
||||
// Unknown target graph for uri:'file:///home' and mime:'inode/directory'
|
||||
// ```
|
||||
//
|
||||
// See the related bug reports:
|
||||
//
|
||||
// https://gitlab.gnome.org/GNOME/localsearch/-/issues/313
|
||||
// https://bugs.launchpad.net/bugs/2077181
|
||||
//
|
||||
//
|
||||
// There is nothing we can do.
|
||||
|
||||
const TRACKER_SETTINGS_SCHEMA: &str = "org.freedesktop.Tracker3.Miner.Files";
|
||||
const KEY_INDEX_RECURSIVE_DIRECTORIES: &str = "index-recursive-directories";
|
||||
const KEY_INDEX_SINGLE_DIRECTORIES: &str = "index-single-directories";
|
||||
|
||||
let search_paths = &config.search_paths;
|
||||
|
||||
let settings = Settings::new(TRACKER_SETTINGS_SCHEMA);
|
||||
let mut recursive_list: StrV = settings.strv(KEY_INDEX_RECURSIVE_DIRECTORIES);
|
||||
let mut single_list: StrV = settings.strv(KEY_INDEX_SINGLE_DIRECTORIES);
|
||||
|
||||
for search_path in search_paths {
|
||||
// We want our search path to be included in the recursive directories or
|
||||
// any directory within the list covers it.
|
||||
ensure_path_in_recursive_indexing_scope(&mut recursive_list, search_path);
|
||||
// We want our search path and its any descendants are not listed in
|
||||
// the index directories list.
|
||||
ensure_path_and_descendants_not_in_single_indexing_scope(&mut single_list, search_path);
|
||||
}
|
||||
|
||||
settings
|
||||
.set_strv(KEY_INDEX_RECURSIVE_DIRECTORIES, recursive_list)
|
||||
.expect("key is not read-only");
|
||||
settings
|
||||
.set_strv(KEY_INDEX_SINGLE_DIRECTORIES, single_list)
|
||||
.expect("key is not be read-only");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_query_string_cleanup_basic() {
|
||||
assert_eq!(query_string_cleanup("test"), "test");
|
||||
assert_eq!(query_string_cleanup("hello world"), "hello world");
|
||||
assert_eq!(query_string_cleanup("file.txt"), "file.txt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_query_string_cleanup_unsupported_chars() {
|
||||
assert_eq!(query_string_cleanup("test'file"), "test file");
|
||||
assert_eq!(query_string_cleanup("test\nfile"), "test file");
|
||||
assert_eq!(query_string_cleanup("test\\file"), "test file");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_query_string_cleanup_multiple_unsupported() {
|
||||
assert_eq!(query_string_cleanup("test'file\nname"), "test file name");
|
||||
assert_eq!(query_string_cleanup("test\'file"), "test file");
|
||||
assert_eq!(query_string_cleanup("\n'test"), " test");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_query_string_cleanup_edge_cases() {
|
||||
assert_eq!(query_string_cleanup(""), "");
|
||||
assert_eq!(query_string_cleanup("'"), " ");
|
||||
assert_eq!(query_string_cleanup("\n"), " ");
|
||||
assert_eq!(query_string_cleanup("\\"), " ");
|
||||
assert_eq!(query_string_cleanup(" '\n\\ "), " ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_query_string_cleanup_mixed_content() {
|
||||
assert_eq!(
|
||||
query_string_cleanup("document's content\nwith\\backslash"),
|
||||
"document s content with backslash"
|
||||
);
|
||||
assert_eq!(
|
||||
query_string_cleanup("path/to'file\nextension\\test"),
|
||||
"path/to file extension test"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
//! File search for KDE, powered by its Baloo engine.
|
||||
|
||||
use super::super::super::EXTENSION_ID;
|
||||
use super::super::super::config::FileSearchConfig;
|
||||
use super::super::super::config::SearchBy;
|
||||
use super::super::should_be_filtered_out;
|
||||
use crate::common::document::{DataSourceReference, Document};
|
||||
use crate::extension::LOCAL_QUERY_SOURCE_TYPE;
|
||||
use crate::extension::OnOpened;
|
||||
use crate::util::file::sync_get_file_icon;
|
||||
use camino::Utf8Path;
|
||||
use configparser::ini::Ini;
|
||||
use configparser::ini::WriteOptions;
|
||||
use futures::stream::Stream;
|
||||
use futures::stream::StreamExt;
|
||||
use std::os::fd::OwnedFd;
|
||||
use std::path::PathBuf;
|
||||
use tokio::io::AsyncBufReadExt;
|
||||
use tokio::io::BufReader;
|
||||
use tokio::process::Child;
|
||||
use tokio::process::Command;
|
||||
use tokio_stream::wrappers::LinesStream;
|
||||
|
||||
/// Baloo does not support scoring, use this score for all the documents.
|
||||
const SCORE: f64 = 1.0;
|
||||
|
||||
/// KDE6 updates the binary name to "baloosearch6", but I believe there still have
|
||||
/// distros using the original name. So we need to check both.
|
||||
fn cli_tool_lookup() -> Option<PathBuf> {
|
||||
use which::which;
|
||||
|
||||
let res_path = which("baloosearch").or_else(|_| which("baloosearch6"));
|
||||
res_path.ok()
|
||||
}
|
||||
|
||||
pub(crate) async fn hits(
|
||||
query_string: &str,
|
||||
_from: usize,
|
||||
size: usize,
|
||||
config: &FileSearchConfig,
|
||||
) -> Result<Vec<(Document, f64)>, String> {
|
||||
// Special cases that will make querying faster.
|
||||
if query_string.is_empty() || size == 0 || config.search_paths.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
// If the tool is not found, return an empty result as well.
|
||||
let Some(tool_path) = cli_tool_lookup() else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
|
||||
let (mut iter, _baloosearch_child_process) =
|
||||
execute_baloosearch_query(tool_path, query_string, size, config)?;
|
||||
|
||||
// Convert results to documents
|
||||
let mut hits: Vec<(Document, f64)> = Vec::new();
|
||||
while let Some(res_file_path) = iter.next().await {
|
||||
let file_path = res_file_path.map_err(|io_err| io_err.to_string())?;
|
||||
|
||||
let icon = sync_get_file_icon(&file_path);
|
||||
let file_path_of_type_path = camino::Utf8Path::new(&file_path);
|
||||
let r#where = file_path_of_type_path
|
||||
.parent()
|
||||
.unwrap_or_else(|| {
|
||||
panic!(
|
||||
"expect path [{}] to have a parent, but it does not",
|
||||
file_path
|
||||
);
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let file_name = file_path_of_type_path.file_name().unwrap_or_else(|| {
|
||||
panic!(
|
||||
"expect path [{}] to have a file name, but it does not",
|
||||
file_path
|
||||
);
|
||||
});
|
||||
let on_opened = OnOpened::Document {
|
||||
url: file_path.clone(),
|
||||
};
|
||||
|
||||
let doc = Document {
|
||||
id: file_path.clone(),
|
||||
title: Some(file_name.to_string()),
|
||||
source: Some(DataSourceReference {
|
||||
r#type: Some(LOCAL_QUERY_SOURCE_TYPE.into()),
|
||||
name: Some(EXTENSION_ID.into()),
|
||||
id: Some(EXTENSION_ID.into()),
|
||||
icon: Some(String::from("font_Filesearch")),
|
||||
}),
|
||||
category: Some(r#where),
|
||||
on_opened: Some(on_opened),
|
||||
url: Some(file_path),
|
||||
icon: Some(icon.to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
hits.push((doc, SCORE));
|
||||
}
|
||||
|
||||
Ok(hits)
|
||||
}
|
||||
|
||||
/// Return an array containing the `baloosearch` command and its arguments.
|
||||
fn build_baloosearch_query(
|
||||
tool_path: PathBuf,
|
||||
query_string: &str,
|
||||
config: &FileSearchConfig,
|
||||
) -> Vec<String> {
|
||||
let tool_path = tool_path
|
||||
.into_os_string()
|
||||
.into_string()
|
||||
.expect("binary path should be UTF-8 encoded");
|
||||
|
||||
let mut args = vec![tool_path];
|
||||
|
||||
match config.search_by {
|
||||
SearchBy::Name => {
|
||||
args.push(format!("filename:{query_string}"));
|
||||
}
|
||||
SearchBy::NameAndContents => {
|
||||
args.push(query_string.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
for search_path in config.search_paths.iter() {
|
||||
args.extend_from_slice(&["-d".into(), search_path.clone()]);
|
||||
}
|
||||
|
||||
args
|
||||
}
|
||||
|
||||
/// Spawn the `baloosearch` child process and return an async iterator over its output,
|
||||
/// allowing us to collect the results asynchronously.
|
||||
///
|
||||
/// # Return value:
|
||||
///
|
||||
/// * impl Stream: an async iterator that will yield the matched files
|
||||
/// * Child: The handle to the baloosearch process. The child process will be
|
||||
/// killed when this handle gets dropped so we need to keep it alive util we
|
||||
/// exhaust the stream.
|
||||
fn execute_baloosearch_query(
|
||||
tool_path: PathBuf,
|
||||
query_string: &str,
|
||||
size: usize,
|
||||
config: &FileSearchConfig,
|
||||
) -> Result<(impl Stream<Item = std::io::Result<String>>, Child), String> {
|
||||
let args = build_baloosearch_query(tool_path, query_string, config);
|
||||
|
||||
let (rx, tx) = std::io::pipe().unwrap();
|
||||
let rx_owned = OwnedFd::from(rx);
|
||||
let async_rx = tokio::net::unix::pipe::Receiver::from_owned_fd(rx_owned).unwrap();
|
||||
let buffered_rx = BufReader::new(async_rx);
|
||||
let lines = LinesStream::new(buffered_rx.lines());
|
||||
|
||||
let child = Command::new(&args[0])
|
||||
.args(&args[1..])
|
||||
.stdout(tx)
|
||||
.stderr(std::process::Stdio::null())
|
||||
// The child process will be killed when the Child instance gets dropped.
|
||||
.kill_on_drop(true)
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to spawn baloosearch: {e}"))?;
|
||||
let config_clone = config.clone();
|
||||
let iter = lines
|
||||
.filter(move |res_path| {
|
||||
std::future::ready({
|
||||
match res_path {
|
||||
Ok(path) => !should_be_filtered_out(&config_clone, path, false, true, true),
|
||||
Err(_) => {
|
||||
// Don't filter out Err() values
|
||||
true
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
.take(size);
|
||||
|
||||
Ok((iter, child))
|
||||
}
|
||||
|
||||
pub(crate) fn apply_config(config: &FileSearchConfig) -> Result<(), String> {
|
||||
// Users can tweak Baloo via its configuration file, below are the fields that
|
||||
// we need to modify:
|
||||
//
|
||||
// * Indexing-Enabled: turn indexing on or off
|
||||
// * only basic indexing: If true, Baloo only indexes file names
|
||||
// * folders: directories to index
|
||||
// * exclude folders: directories to skip
|
||||
//
|
||||
// ```ini
|
||||
// [Basic Settings]
|
||||
// Indexing-Enabled=true
|
||||
//
|
||||
// [General]
|
||||
// only basic indexing=true
|
||||
// folders[$e]=$HOME/
|
||||
// exclude folders[$e]=$HOME/FolderA/,$HOME/FolderB/
|
||||
// ```
|
||||
|
||||
const SECTION_GENERAL: &str = "General";
|
||||
const KEY_INCLUDE_FOLDERS: &str = "folders[$e]";
|
||||
const KEY_EXCLUDE_FOLDERS: &str = "exclude folders[$e]";
|
||||
const FOLDERS_SEPARATOR: &str = ",";
|
||||
|
||||
let rc_file_path = {
|
||||
let mut home = dirs::home_dir()
|
||||
.expect("cannot find the home directory, Coco should never run in such a environment");
|
||||
home.push(".config/baloofilerc");
|
||||
home
|
||||
};
|
||||
|
||||
// Parse and load the rc file, it is in format INI
|
||||
//
|
||||
// Use `new_cs()`, the case-sensitive version of constructor as the config
|
||||
// file contains uppercase letters, so it is case-sensitive.
|
||||
let mut baloo_config = Ini::new_cs();
|
||||
if rc_file_path.try_exists().map_err(|e| e.to_string())? {
|
||||
let _ = baloo_config.load(rc_file_path.as_path())?;
|
||||
}
|
||||
|
||||
// Ensure indexing is enabled
|
||||
let _ = baloo_config.setstr("Basic Settings", "Indexing-Enabled", Some("true"));
|
||||
|
||||
// Let baloo index file content if we need that
|
||||
if config.search_by == SearchBy::NameAndContents {
|
||||
let _ = baloo_config.setstr(SECTION_GENERAL, "only basic indexing", Some("false"));
|
||||
}
|
||||
|
||||
let mut include_folders = {
|
||||
match baloo_config.get(SECTION_GENERAL, KEY_INCLUDE_FOLDERS) {
|
||||
Some(str) => str
|
||||
.split(FOLDERS_SEPARATOR)
|
||||
.map(|str| str.to_string())
|
||||
.collect::<Vec<String>>(),
|
||||
None => Vec::new(),
|
||||
}
|
||||
};
|
||||
|
||||
let mut exclude_folders = {
|
||||
match baloo_config.get(SECTION_GENERAL, KEY_EXCLUDE_FOLDERS) {
|
||||
Some(str) => str
|
||||
.split(FOLDERS_SEPARATOR)
|
||||
.map(|str| str.to_string())
|
||||
.collect::<Vec<String>>(),
|
||||
None => Vec::new(),
|
||||
}
|
||||
};
|
||||
|
||||
fn ensure_path_included_include_folders(
|
||||
include_folders: &mut Vec<String>,
|
||||
search_path: &Utf8Path,
|
||||
) {
|
||||
for include_folder in include_folders.iter() {
|
||||
let include_folder = Utf8Path::new(include_folder.as_str());
|
||||
if search_path.starts_with(include_folder) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
include_folders.push(search_path.as_str().to_string());
|
||||
}
|
||||
|
||||
fn ensure_path_and_descendants_not_excluded(
|
||||
exclude_folders: &mut Vec<String>,
|
||||
search_path: &Utf8Path,
|
||||
) {
|
||||
let mut items_to_remove = Vec::new();
|
||||
for (idx, exclude_folder) in exclude_folders.iter().enumerate() {
|
||||
let exclude_folder = Utf8Path::new(exclude_folder);
|
||||
|
||||
if exclude_folder.starts_with(search_path) {
|
||||
items_to_remove.push(idx);
|
||||
}
|
||||
}
|
||||
|
||||
for idx in items_to_remove.into_iter().rev() {
|
||||
exclude_folders.remove(idx);
|
||||
}
|
||||
}
|
||||
|
||||
for search_path in config.search_paths.iter() {
|
||||
let search_path = Utf8Path::new(search_path.as_str());
|
||||
|
||||
ensure_path_included_include_folders(&mut include_folders, search_path);
|
||||
ensure_path_and_descendants_not_excluded(&mut exclude_folders, search_path);
|
||||
}
|
||||
|
||||
let include_folders_str: String = include_folders.as_slice().join(FOLDERS_SEPARATOR);
|
||||
let exclude_folders_str: String = exclude_folders.as_slice().join(FOLDERS_SEPARATOR);
|
||||
|
||||
let _ = baloo_config.set(
|
||||
SECTION_GENERAL,
|
||||
KEY_INCLUDE_FOLDERS,
|
||||
Some(include_folders_str),
|
||||
);
|
||||
let _ = baloo_config.set(
|
||||
SECTION_GENERAL,
|
||||
KEY_EXCLUDE_FOLDERS,
|
||||
Some(exclude_folders_str),
|
||||
);
|
||||
|
||||
baloo_config
|
||||
.pretty_write(rc_file_path.as_path(), &WriteOptions::new())
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
mod gnome;
|
||||
mod kde;
|
||||
|
||||
use super::super::config::FileSearchConfig;
|
||||
use crate::common::document::Document;
|
||||
use crate::util::LinuxDesktopEnvironment;
|
||||
use crate::util::get_linux_desktop_environment;
|
||||
use std::ops::Deref;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
static DESKTOP_ENVIRONMENT: LazyLock<Option<LinuxDesktopEnvironment>> =
|
||||
LazyLock::new(|| get_linux_desktop_environment());
|
||||
|
||||
/// Dispatch to implementations powered by different backends.
|
||||
pub(crate) async fn hits(
|
||||
query_string: &str,
|
||||
from: usize,
|
||||
size: usize,
|
||||
config: &FileSearchConfig,
|
||||
) -> Result<Vec<(Document, f64)>, String> {
|
||||
let de = DESKTOP_ENVIRONMENT.deref();
|
||||
match de {
|
||||
Some(LinuxDesktopEnvironment::Gnome) => gnome::hits(query_string, from, size, config).await,
|
||||
Some(LinuxDesktopEnvironment::Kde) => kde::hits(query_string, from, size, config).await,
|
||||
Some(LinuxDesktopEnvironment::Unsupported {
|
||||
xdg_current_desktop: _,
|
||||
}) => {
|
||||
return Err("file search is not supported on this desktop environment".into());
|
||||
}
|
||||
None => {
|
||||
return Err("could not determine Linux desktop environment".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn apply_config(config: &FileSearchConfig) -> Result<(), String> {
|
||||
let de = DESKTOP_ENVIRONMENT.deref();
|
||||
match de {
|
||||
Some(LinuxDesktopEnvironment::Gnome) => gnome::apply_config(config),
|
||||
Some(LinuxDesktopEnvironment::Kde) => kde::apply_config(config),
|
||||
Some(LinuxDesktopEnvironment::Unsupported {
|
||||
xdg_current_desktop: _,
|
||||
}) => {
|
||||
return Err("file search is not supported on this desktop environment".into());
|
||||
}
|
||||
None => {
|
||||
return Err("could not determine Linux desktop environment".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
use super::super::EXTENSION_ID;
|
||||
use super::super::config::FileSearchConfig;
|
||||
use super::super::config::SearchBy;
|
||||
use super::should_be_filtered_out;
|
||||
use crate::common::document::{DataSourceReference, Document};
|
||||
use crate::extension::LOCAL_QUERY_SOURCE_TYPE;
|
||||
use crate::extension::OnOpened;
|
||||
use crate::util::file::get_file_icon;
|
||||
use crate::util::file::sync_get_file_icon;
|
||||
use futures::stream::Stream;
|
||||
use futures::stream::StreamExt;
|
||||
use std::os::fd::OwnedFd;
|
||||
@@ -24,7 +25,7 @@ pub(crate) async fn hits(
|
||||
size: usize,
|
||||
config: &FileSearchConfig,
|
||||
) -> Result<Vec<(Document, f64)>, String> {
|
||||
let (mut iter, mut mdfind_child_process) =
|
||||
let (mut iter, _mdfind_child_process) =
|
||||
execute_mdfind_query(&query_string, from, size, &config)?;
|
||||
|
||||
// Convert results to documents
|
||||
@@ -32,7 +33,7 @@ pub(crate) async fn hits(
|
||||
while let Some(res_file_path) = iter.next().await {
|
||||
let file_path = res_file_path.map_err(|io_err| io_err.to_string())?;
|
||||
|
||||
let icon = get_file_icon(file_path.clone()).await;
|
||||
let icon = sync_get_file_icon(&file_path);
|
||||
let file_path_of_type_path = camino::Utf8Path::new(&file_path);
|
||||
let r#where = file_path_of_type_path
|
||||
.parent()
|
||||
@@ -72,12 +73,6 @@ pub(crate) async fn hits(
|
||||
|
||||
hits.push((doc, SCORE));
|
||||
}
|
||||
// Kill the mdfind process once we get the needed results to prevent zombie
|
||||
// processes.
|
||||
mdfind_child_process
|
||||
.kill()
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))?;
|
||||
|
||||
Ok(hits)
|
||||
}
|
||||
@@ -88,13 +83,28 @@ fn build_mdfind_query(query_string: &str, config: &FileSearchConfig) -> Vec<Stri
|
||||
|
||||
match config.search_by {
|
||||
SearchBy::Name => {
|
||||
args.push(format!("kMDItemFSName == '*{}*'", query_string));
|
||||
// The tailing char 'c' makes the search case-insensitive.
|
||||
//
|
||||
// According to [1], we should use this syntax "kMDItemFSName ==[c] '*{}*'",
|
||||
// but it does not work on my machine (macOS 26 beta 7), and you
|
||||
// can find similar complaints as well [2].
|
||||
//
|
||||
// [1]: https://developer.apple.com/library/archive/documentation/Carbon/Conceptual/SpotlightQuery/Concepts/QueryFormat.html
|
||||
// [2]: https://apple.stackexchange.com/q/263671/394687
|
||||
args.push(format!("kMDItemFSName == '*{}*'c", query_string));
|
||||
}
|
||||
SearchBy::NameAndContents => {
|
||||
args.push(format!(
|
||||
"kMDItemFSName == '*{}*' || kMDItemTextContent == '{}'",
|
||||
query_string, query_string
|
||||
));
|
||||
// Do not specify any File System Metadata Attribute Keys to search
|
||||
// all of them, it is case-insensitive by default.
|
||||
//
|
||||
// Previously, we use:
|
||||
//
|
||||
// "kMDItemFSName == '*{}*' || kMDItemTextContent == '{}'"
|
||||
//
|
||||
// But the kMDItemTextContent attribute does not work as expected.
|
||||
// For example, if a PDF document contains both "Waterloo" and
|
||||
// "waterloo", it is only matched by "Waterloo".
|
||||
args.push(query_string.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,8 +124,9 @@ fn build_mdfind_query(query_string: &str, config: &FileSearchConfig) -> Vec<Stri
|
||||
/// # Return value:
|
||||
///
|
||||
/// * impl Stream: an async iterator that will yield the matched files
|
||||
/// * Child: The handle to the mdfind process, we need to kill it once we
|
||||
/// collect all the results to avoid zombie processes.
|
||||
/// * Child: The handle to the mdfind process. The child process will be killed
|
||||
/// when this handle gets dropped, we need to keep it alive until we exhaust
|
||||
/// all the query results.
|
||||
fn execute_mdfind_query(
|
||||
query_string: &str,
|
||||
from: usize,
|
||||
@@ -133,6 +144,7 @@ fn execute_mdfind_query(
|
||||
.args(&args[1..])
|
||||
.stdout(tx)
|
||||
.stderr(std::process::Stdio::null())
|
||||
.kill_on_drop(true)
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to spawn mdfind: {}", e))?;
|
||||
let config_clone = config.clone();
|
||||
@@ -140,7 +152,7 @@ fn execute_mdfind_query(
|
||||
.filter(move |res_path| {
|
||||
std::future::ready({
|
||||
match res_path {
|
||||
Ok(path) => !should_be_filtered_out(&config_clone, path),
|
||||
Ok(path) => !should_be_filtered_out(&config_clone, path, false, true, true),
|
||||
Err(_) => {
|
||||
// Don't filter out Err() values
|
||||
true
|
||||
@@ -154,33 +166,25 @@ fn execute_mdfind_query(
|
||||
Ok((iter, child))
|
||||
}
|
||||
|
||||
/// If `file_path` should be removed from the search results given the filter
|
||||
/// conditions specified in `config`.
|
||||
fn should_be_filtered_out(config: &FileSearchConfig, file_path: &str) -> bool {
|
||||
let is_excluded = config
|
||||
.exclude_paths
|
||||
.iter()
|
||||
.any(|exclude_path| file_path.starts_with(exclude_path));
|
||||
|
||||
if is_excluded {
|
||||
return true;
|
||||
}
|
||||
|
||||
let matches_file_type = if config.file_types.is_empty() {
|
||||
true
|
||||
} else {
|
||||
let path_obj = camino::Utf8Path::new(&file_path);
|
||||
if let Some(extension) = path_obj.extension() {
|
||||
config
|
||||
.file_types
|
||||
.iter()
|
||||
.any(|file_type| file_type == extension)
|
||||
} else {
|
||||
// `config.file_types` is not empty, then the search results
|
||||
// should have extensions.
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
!matches_file_type
|
||||
pub(crate) fn apply_config(_: &FileSearchConfig) -> Result<(), String> {
|
||||
// By default, macOS indexes all the files within a volume if indexing is
|
||||
// enabled. So, to ensure our search paths are indexed by Spotlight,
|
||||
// theoretically, we can do the following things:
|
||||
//
|
||||
// 1. Ensure indexing is enabled on the volumes where our search paths reside.
|
||||
// However, we cannot do this as doing so requires `sudo`.
|
||||
//
|
||||
// 2. Ensure the search paths are not excluded from indexing scope. Users can
|
||||
// stop Spotlight from indexing a directory by:
|
||||
// 1. adding it to the "Privacy" list in 'System Settings'. Coco cannot
|
||||
// modify this list, since the only way to change it is manually
|
||||
// through System Settings.
|
||||
// 2. Renaming directory name, adding a `.noindex` file extension to it.
|
||||
// I don't want to use this trick, users won't feel comfortable and it
|
||||
// could break at any time.
|
||||
// 3. Creating a `.metadata_never_index` file within the directory (no longer works
|
||||
// since macOS Mojave)
|
||||
//
|
||||
// There is nothing we can do.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,10 +1,396 @@
|
||||
#[cfg(target_os = "macos")]
|
||||
mod macos;
|
||||
#[cfg(target_os = "windows")]
|
||||
mod windows;
|
||||
use cfg_if::cfg_if;
|
||||
|
||||
// `hits()` function is platform-specific, export the corresponding impl.
|
||||
#[cfg(target_os = "macos")]
|
||||
pub(crate) use macos::hits;
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) use windows::hits;
|
||||
// * hits: the implementation of search
|
||||
//
|
||||
// * apply_config: Routines that should be performed to keep "other things"
|
||||
// synchronous with the passed configuration.
|
||||
// Currently, "other things" only include system indexer's setting entries.
|
||||
cfg_if! {
|
||||
if #[cfg(target_os = "linux")] {
|
||||
mod linux;
|
||||
pub(crate) use linux::hits;
|
||||
pub(crate) use linux::apply_config;
|
||||
} else if #[cfg(target_os = "macos")] {
|
||||
mod macos;
|
||||
pub(crate) use macos::hits;
|
||||
pub(crate) use macos::apply_config;
|
||||
} else if #[cfg(target_os = "windows")] {
|
||||
mod windows;
|
||||
pub(crate) use windows::hits;
|
||||
pub(crate) use windows::apply_config;
|
||||
}
|
||||
}
|
||||
|
||||
cfg_if! {
|
||||
if #[cfg(not(target_os = "windows"))] {
|
||||
use super::config::FileSearchConfig;
|
||||
use camino::Utf8Path;
|
||||
}
|
||||
}
|
||||
|
||||
/// If `file_path` should be removed from the search results given the filter
|
||||
/// conditions specified in `config`.
|
||||
#[cfg(not(target_os = "windows"))] // Not used on Windows
|
||||
pub(crate) fn should_be_filtered_out(
|
||||
config: &FileSearchConfig,
|
||||
file_path: &str,
|
||||
check_search_paths: bool,
|
||||
check_exclude_paths: bool,
|
||||
check_file_type: bool,
|
||||
) -> bool {
|
||||
let file_path = Utf8Path::new(file_path);
|
||||
|
||||
if check_search_paths {
|
||||
// search path
|
||||
let in_search_paths = config.search_paths.iter().any(|search_path| {
|
||||
let search_path = Utf8Path::new(search_path);
|
||||
file_path.starts_with(search_path)
|
||||
});
|
||||
if !in_search_paths {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if check_exclude_paths {
|
||||
// exclude path
|
||||
let is_excluded = config
|
||||
.exclude_paths
|
||||
.iter()
|
||||
.any(|exclude_path| file_path.starts_with(exclude_path));
|
||||
if is_excluded {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if check_file_type {
|
||||
// file type
|
||||
let matches_file_type = if config.file_types.is_empty() {
|
||||
true
|
||||
} else {
|
||||
let path_obj = camino::Utf8Path::new(&file_path);
|
||||
if let Some(extension) = path_obj.extension() {
|
||||
config
|
||||
.file_types
|
||||
.iter()
|
||||
.any(|file_type| file_type == extension)
|
||||
} else {
|
||||
// `config.file_types` is not empty, the hit files should have extensions.
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
if !matches_file_type {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
// should_be_filtered_out() is not defined for Windows
|
||||
#[cfg(all(test, not(target_os = "windows")))]
|
||||
mod tests {
|
||||
use super::super::config::SearchBy;
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_should_be_filtered_out_with_no_check() {
|
||||
let config = FileSearchConfig {
|
||||
search_paths: vec!["/home/user/Documents".to_string()],
|
||||
exclude_paths: vec![],
|
||||
file_types: vec!["fffffff".into()],
|
||||
search_by: SearchBy::Name,
|
||||
};
|
||||
|
||||
assert!(!should_be_filtered_out(
|
||||
&config, "abbc", false, false, false
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_be_filtered_out_search_paths() {
|
||||
let config = FileSearchConfig {
|
||||
search_paths: vec![
|
||||
"/home/user/Documents".to_string(),
|
||||
"/home/user/Downloads".to_string(),
|
||||
],
|
||||
exclude_paths: vec![],
|
||||
file_types: vec![],
|
||||
search_by: SearchBy::Name,
|
||||
};
|
||||
|
||||
// Files in search paths should not be filtered
|
||||
assert!(!should_be_filtered_out(
|
||||
&config,
|
||||
"/home/user/Documents/file.txt",
|
||||
true,
|
||||
true,
|
||||
true
|
||||
));
|
||||
assert!(!should_be_filtered_out(
|
||||
&config,
|
||||
"/home/user/Downloads/image.jpg",
|
||||
true,
|
||||
true,
|
||||
true
|
||||
));
|
||||
assert!(!should_be_filtered_out(
|
||||
&config,
|
||||
"/home/user/Documents/folder/file.txt",
|
||||
true,
|
||||
true,
|
||||
true
|
||||
));
|
||||
|
||||
// Files not in search paths should be filtered
|
||||
assert!(should_be_filtered_out(
|
||||
&config,
|
||||
"/home/user/Pictures/photo.jpg",
|
||||
true,
|
||||
true,
|
||||
true
|
||||
));
|
||||
assert!(should_be_filtered_out(
|
||||
&config,
|
||||
"/tmp/tempfile",
|
||||
true,
|
||||
true,
|
||||
true
|
||||
));
|
||||
assert!(should_be_filtered_out(
|
||||
&config,
|
||||
"/usr/bin/ls",
|
||||
true,
|
||||
true,
|
||||
true
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_be_filtered_out_exclude_paths() {
|
||||
let config = FileSearchConfig {
|
||||
search_paths: vec!["/home/user".to_string()],
|
||||
exclude_paths: vec![
|
||||
"/home/user/Trash".to_string(),
|
||||
"/home/user/.cache".to_string(),
|
||||
],
|
||||
file_types: vec![],
|
||||
search_by: SearchBy::Name,
|
||||
};
|
||||
|
||||
// Files in search paths but not excluded should not be filtered
|
||||
assert!(!should_be_filtered_out(
|
||||
&config,
|
||||
"/home/user/Documents/file.txt",
|
||||
true,
|
||||
true,
|
||||
true
|
||||
));
|
||||
assert!(!should_be_filtered_out(
|
||||
&config,
|
||||
"/home/user/Downloads/image.jpg",
|
||||
true,
|
||||
true,
|
||||
true
|
||||
));
|
||||
|
||||
// Files in excluded paths should be filtered
|
||||
assert!(should_be_filtered_out(
|
||||
&config,
|
||||
"/home/user/Trash/deleted_file",
|
||||
true,
|
||||
true,
|
||||
true
|
||||
));
|
||||
assert!(should_be_filtered_out(
|
||||
&config,
|
||||
"/home/user/.cache/temp",
|
||||
true,
|
||||
true,
|
||||
true
|
||||
));
|
||||
assert!(should_be_filtered_out(
|
||||
&config,
|
||||
"/home/user/Trash/folder/file.txt",
|
||||
true,
|
||||
true,
|
||||
true
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_be_filtered_out_file_types() {
|
||||
let config = FileSearchConfig {
|
||||
search_paths: vec!["/home/user/Documents".to_string()],
|
||||
exclude_paths: vec![],
|
||||
file_types: vec!["txt".to_string(), "md".to_string()],
|
||||
search_by: SearchBy::Name,
|
||||
};
|
||||
|
||||
// Files with allowed extensions should not be filtered
|
||||
assert!(!should_be_filtered_out(
|
||||
&config,
|
||||
"/home/user/Documents/notes.txt",
|
||||
true,
|
||||
true,
|
||||
true
|
||||
));
|
||||
assert!(!should_be_filtered_out(
|
||||
&config,
|
||||
"/home/user/Documents/readme.md",
|
||||
true,
|
||||
true,
|
||||
true
|
||||
));
|
||||
|
||||
// Files with disallowed extensions should be filtered
|
||||
assert!(should_be_filtered_out(
|
||||
&config,
|
||||
"/home/user/Documents/image.jpg",
|
||||
true,
|
||||
true,
|
||||
true
|
||||
));
|
||||
assert!(should_be_filtered_out(
|
||||
&config,
|
||||
"/home/user/Documents/document.pdf",
|
||||
true,
|
||||
true,
|
||||
true
|
||||
));
|
||||
|
||||
// Files without extensions should be filtered when file_types is not empty
|
||||
assert!(should_be_filtered_out(
|
||||
&config,
|
||||
"/home/user/Documents/file",
|
||||
true,
|
||||
true,
|
||||
true
|
||||
));
|
||||
assert!(should_be_filtered_out(
|
||||
&config,
|
||||
"/home/user/Documents/folder",
|
||||
true,
|
||||
true,
|
||||
true
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_be_filtered_out_empty_file_types() {
|
||||
let config = FileSearchConfig {
|
||||
search_paths: vec!["/home/user/Documents".to_string()],
|
||||
exclude_paths: vec![],
|
||||
file_types: vec![],
|
||||
search_by: SearchBy::Name,
|
||||
};
|
||||
|
||||
// When file_types is empty, all file types should be allowed
|
||||
assert!(!should_be_filtered_out(
|
||||
&config,
|
||||
"/home/user/Documents/file.txt",
|
||||
true,
|
||||
true,
|
||||
true
|
||||
));
|
||||
assert!(!should_be_filtered_out(
|
||||
&config,
|
||||
"/home/user/Documents/image.jpg",
|
||||
true,
|
||||
true,
|
||||
true
|
||||
));
|
||||
assert!(!should_be_filtered_out(
|
||||
&config,
|
||||
"/home/user/Documents/document",
|
||||
true,
|
||||
true,
|
||||
true
|
||||
));
|
||||
assert!(!should_be_filtered_out(
|
||||
&config,
|
||||
"/home/user/Documents/folder/",
|
||||
true,
|
||||
true,
|
||||
true
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_be_filtered_out_combined_filters() {
|
||||
let config = FileSearchConfig {
|
||||
search_paths: vec!["/home/user".to_string()],
|
||||
exclude_paths: vec!["/home/user/Trash".to_string()],
|
||||
file_types: vec!["txt".to_string()],
|
||||
search_by: SearchBy::Name,
|
||||
};
|
||||
|
||||
// Should pass all filters: in search path, not excluded, and correct file type
|
||||
assert!(!should_be_filtered_out(
|
||||
&config,
|
||||
"/home/user/Documents/notes.txt",
|
||||
true,
|
||||
true,
|
||||
true
|
||||
));
|
||||
|
||||
// Fails file type filter
|
||||
assert!(should_be_filtered_out(
|
||||
&config,
|
||||
"/home/user/Documents/image.jpg",
|
||||
true,
|
||||
true,
|
||||
true
|
||||
));
|
||||
|
||||
// Fails exclude path filter
|
||||
assert!(should_be_filtered_out(
|
||||
&config,
|
||||
"/home/user/Trash/deleted.txt",
|
||||
true,
|
||||
true,
|
||||
true
|
||||
));
|
||||
|
||||
// Fails search path filter
|
||||
assert!(should_be_filtered_out(
|
||||
&config,
|
||||
"/tmp/temp.txt",
|
||||
true,
|
||||
true,
|
||||
true
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_be_filtered_out_edge_cases() {
|
||||
let config = FileSearchConfig {
|
||||
search_paths: vec!["/home/user".to_string()],
|
||||
exclude_paths: vec![],
|
||||
file_types: vec!["txt".to_string()],
|
||||
search_by: SearchBy::Name,
|
||||
};
|
||||
|
||||
// Empty path
|
||||
assert!(should_be_filtered_out(&config, "", true, true, true));
|
||||
|
||||
// Root path
|
||||
assert!(should_be_filtered_out(&config, "/", true, true, true));
|
||||
|
||||
// Path that starts with search path but continues differently
|
||||
assert!(!should_be_filtered_out(
|
||||
&config,
|
||||
"/home/user/document.txt",
|
||||
true,
|
||||
true,
|
||||
true
|
||||
));
|
||||
assert!(should_be_filtered_out(
|
||||
&config,
|
||||
"/home/user_other/file.txt",
|
||||
true,
|
||||
true,
|
||||
true
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
//! Wraps Windows `ISearchCrawlScopeManager`
|
||||
|
||||
mod searchapi_h_bindings;
|
||||
|
||||
use searchapi_h_bindings::CLSID_CSEARCH_MANAGER;
|
||||
use searchapi_h_bindings::IID_ISEARCH_MANAGER;
|
||||
use searchapi_h_bindings::{
|
||||
HRESULT, ISearchCatalogManager, ISearchCatalogManagerVtbl, ISearchCrawlScopeManager,
|
||||
ISearchCrawlScopeManagerVtbl, ISearchManager,
|
||||
};
|
||||
use std::ffi::OsStr;
|
||||
use std::ffi::OsString;
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::ptr::null_mut;
|
||||
use windows::core::w;
|
||||
use windows_sys::Win32::Foundation::S_OK;
|
||||
use windows_sys::Win32::System::Com::{
|
||||
CLSCTX_LOCAL_SERVER, COINIT_APARTMENTTHREADED, CoCreateInstance, CoInitializeEx, CoUninitialize,
|
||||
};
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[error("{msg}, function [{function}], HRESULT [{hresult}]")]
|
||||
pub(crate) struct WindowSearchApiError {
|
||||
function: &'static str,
|
||||
hresult: HRESULT,
|
||||
msg: String,
|
||||
}
|
||||
|
||||
/// See doc of [`Rule`].
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub(crate) enum RuleMode {
|
||||
Inclusion,
|
||||
Exclusion,
|
||||
}
|
||||
|
||||
/// A rule adds or removes one or more paths to/from the Windows Search index.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct Rule {
|
||||
/// A path or path pattern (wildcard supported, only for exclusion rule) that
|
||||
/// specifies the paths that this rule applies to.
|
||||
///
|
||||
/// The rules used by Windows Search actually specify URLs rather than paths,
|
||||
/// but we only care about paths, i.e., URLs with schema `file://`
|
||||
pub(crate) paths: PathBuf,
|
||||
/// Add or remove paths to/from the index.
|
||||
pub(crate) mode: RuleMode,
|
||||
}
|
||||
|
||||
/// A wrapper around Window's `ISearchCrawlScopeManager` type
|
||||
pub(crate) struct CrawlScopeManager {
|
||||
i_search_crawl_scope_manager: *mut ISearchCrawlScopeManager,
|
||||
}
|
||||
|
||||
impl CrawlScopeManager {
|
||||
fn vtable(&self) -> *mut ISearchCrawlScopeManagerVtbl {
|
||||
unsafe { (*self.i_search_crawl_scope_manager).lpVtbl }
|
||||
}
|
||||
|
||||
pub(crate) fn new() -> Result<Self, WindowSearchApiError> {
|
||||
unsafe {
|
||||
// 1. Initialize the COM library, use Apartment-threading as Self is not Send/Sync
|
||||
let hr = CoInitializeEx(null_mut(), COINIT_APARTMENTTHREADED as u32);
|
||||
if hr != S_OK {
|
||||
return Err(WindowSearchApiError {
|
||||
function: "CoInitializeEx()",
|
||||
hresult: hr,
|
||||
msg: "failed to initialize the COM library".into(),
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Create an instance of the CSearchManager.
|
||||
let mut search_manager: *mut ISearchManager = null_mut();
|
||||
let hr = CoCreateInstance(
|
||||
&CLSID_CSEARCH_MANAGER, // CLSID of the object
|
||||
null_mut(), // No outer unknown
|
||||
CLSCTX_LOCAL_SERVER, // Server context
|
||||
&IID_ISEARCH_MANAGER, // IID of the interface we want
|
||||
&mut search_manager as *mut _ as *mut _, // Pointer to receive the interface
|
||||
);
|
||||
if hr != S_OK {
|
||||
return Err(WindowSearchApiError {
|
||||
function: "CoCreateInstance()",
|
||||
hresult: hr,
|
||||
msg: "failed to initialize ISearchManager".into(),
|
||||
});
|
||||
}
|
||||
assert!(!search_manager.is_null());
|
||||
|
||||
let search_manger_vtable = (*search_manager).lpVtbl;
|
||||
let search_manager_fn_get_catalog = (*search_manger_vtable).GetCatalog.unwrap();
|
||||
let mut search_catalog_manager: *mut ISearchCatalogManager = null_mut();
|
||||
let string_literal_system_index = w!("SystemIndex");
|
||||
let hr: HRESULT = search_manager_fn_get_catalog(
|
||||
search_manager,
|
||||
string_literal_system_index.0,
|
||||
&mut search_catalog_manager as *mut *mut ISearchCatalogManager,
|
||||
);
|
||||
if hr != S_OK {
|
||||
return Err(WindowSearchApiError {
|
||||
function: "ISearchManager::GetCatalog()",
|
||||
hresult: hr,
|
||||
msg: "failed to initialize ISearchCatalogManager".into(),
|
||||
});
|
||||
}
|
||||
assert!(!search_catalog_manager.is_null());
|
||||
|
||||
let search_catalog_manager_vtable: *mut ISearchCatalogManagerVtbl =
|
||||
(*search_catalog_manager).lpVtbl;
|
||||
let fn_get_crawl_scope_manager = (*search_catalog_manager_vtable)
|
||||
.GetCrawlScopeManager
|
||||
.unwrap();
|
||||
let mut search_crawl_scope_manager: *mut ISearchCrawlScopeManager = null_mut();
|
||||
let hr =
|
||||
fn_get_crawl_scope_manager(search_catalog_manager, &mut search_crawl_scope_manager);
|
||||
if hr != S_OK {
|
||||
return Err(WindowSearchApiError {
|
||||
function: "ISearchCatalogManager::GetCrawlScopeManager()",
|
||||
hresult: hr,
|
||||
msg: "failed to initialize ISearchCrawlScopeManager".into(),
|
||||
});
|
||||
}
|
||||
assert!(!search_crawl_scope_manager.is_null());
|
||||
|
||||
Ok(Self {
|
||||
i_search_crawl_scope_manager: search_crawl_scope_manager,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Does nothing unless you [`commit()`] the changes.
|
||||
pub(crate) fn add_rule(&mut self, rule: Rule) -> Result<(), WindowSearchApiError> {
|
||||
unsafe {
|
||||
let vtable = self.vtable();
|
||||
|
||||
let fn_add_rule = (*vtable).AddUserScopeRule.unwrap();
|
||||
|
||||
let url: Vec<u16> = encode_path(&rule.paths);
|
||||
let inclusion = (rule.mode == RuleMode::Inclusion) as i32;
|
||||
let override_child_rules = true as i32;
|
||||
let follow_flag = 0x1_u32; /* FF_INDEXCOMPLEXURLS */
|
||||
|
||||
let hr = fn_add_rule(
|
||||
self.i_search_crawl_scope_manager,
|
||||
url.as_ptr(),
|
||||
inclusion,
|
||||
override_child_rules,
|
||||
follow_flag,
|
||||
);
|
||||
|
||||
if hr != S_OK {
|
||||
return Err(WindowSearchApiError {
|
||||
function: "ISearchCrawlScopeManager::AddUserScopeRule()",
|
||||
hresult: hr,
|
||||
msg: "failed to add scope rule".into(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn is_path_included<P: AsRef<Path> + ?Sized>(
|
||||
&self,
|
||||
path: &P,
|
||||
) -> Result<bool, WindowSearchApiError> {
|
||||
unsafe {
|
||||
let vtable = self.vtable();
|
||||
let fn_included_in_crawl_scope = (*vtable).IncludedInCrawlScope.unwrap();
|
||||
let path: Vec<u16> = encode_path(path);
|
||||
|
||||
let mut included: i32 = 0 /* false */;
|
||||
|
||||
let hr = fn_included_in_crawl_scope(
|
||||
self.i_search_crawl_scope_manager,
|
||||
path.as_ptr(),
|
||||
&mut included,
|
||||
);
|
||||
if hr != S_OK {
|
||||
return Err(WindowSearchApiError {
|
||||
function: "ISearchCrawlScopeManager::IncludedInCrawlScope()",
|
||||
hresult: hr,
|
||||
msg: "failed to call IncludedInCrawlScope()".into(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(included == 1)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn commit(&self) -> Result<(), WindowSearchApiError> {
|
||||
unsafe {
|
||||
let vtable = self.vtable();
|
||||
let fn_commit = (*vtable).SaveAll.unwrap();
|
||||
let hr = fn_commit(self.i_search_crawl_scope_manager);
|
||||
if hr != S_OK {
|
||||
return Err(WindowSearchApiError {
|
||||
function: "ISearchCrawlScopeManager::SaveAll()",
|
||||
hresult: hr,
|
||||
msg: "failed to commit the changes".into(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for CrawlScopeManager {
|
||||
fn drop(&mut self) {
|
||||
unsafe {
|
||||
CoUninitialize();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_path<P: AsRef<Path> + ?Sized>(path: &P) -> Vec<u16> {
|
||||
let mut buffer = OsString::new();
|
||||
|
||||
// schema
|
||||
buffer.push("file:///");
|
||||
buffer.push(path.as_ref().as_os_str());
|
||||
|
||||
osstr_to_wstr(&buffer)
|
||||
}
|
||||
|
||||
fn osstr_to_wstr<S: AsRef<OsStr> + ?Sized>(str: &S) -> Vec<u16> {
|
||||
let os_str: &OsStr = str.as_ref();
|
||||
let mut chars = os_str.encode_wide().collect::<Vec<u16>>();
|
||||
chars.push(0 /* NUL */);
|
||||
|
||||
chars
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
//! Rust binding of the types and functions declared in 'searchapi.h'
|
||||
|
||||
#![allow(unused)]
|
||||
#![allow(non_camel_case_types)]
|
||||
#![allow(non_snake_case)]
|
||||
#![allow(non_upper_case_globals)]
|
||||
#![allow(unsafe_op_in_unsafe_fn)]
|
||||
#![allow(unnecessary_transmutes)]
|
||||
|
||||
include!(concat!(env!("OUT_DIR"), "/searchapi_bindings.rs"));
|
||||
|
||||
// The bindings.rs contains a GUID type as well, we use the one provided by
|
||||
// the windows_sys crate here.
|
||||
use windows_sys::core::GUID as WIN_SYS_GUID;
|
||||
|
||||
// https://github.com/search?q=CLSID_CSearchManager+language%3AC&type=code&l=C
|
||||
pub(crate) static CLSID_CSEARCH_MANAGER: WIN_SYS_GUID = WIN_SYS_GUID {
|
||||
data1: 0x7d096c5f,
|
||||
data2: 0xac08,
|
||||
data3: 0x4f1f,
|
||||
data4: [0xbe, 0xb7, 0x5c, 0x22, 0xc5, 0x17, 0xce, 0x39],
|
||||
};
|
||||
|
||||
// https://github.com/search?q=IID_ISearchManager+language%3AC&type=code
|
||||
pub(crate) static IID_ISEARCH_MANAGER: WIN_SYS_GUID = WIN_SYS_GUID {
|
||||
data1: 0xAB310581,
|
||||
data2: 0xac80,
|
||||
data3: 0x11d1,
|
||||
data4: [0x8d, 0xf3, 0x00, 0xc0, 0x4f, 0xb6, 0xef, 0x69],
|
||||
};
|
||||
@@ -3,13 +3,17 @@
|
||||
//! https://github.com/IRONAGE-Park/rag-sample/blob/3f0ad8c8012026cd3a7e453d08f041609426cb91/src/native/windows.rs
|
||||
//! is the starting point of this implementation.
|
||||
|
||||
mod crawl_scope_manager;
|
||||
|
||||
use super::super::EXTENSION_ID;
|
||||
use super::super::config::FileSearchConfig;
|
||||
use super::super::config::SearchBy;
|
||||
use crate::common::document::{DataSourceReference, Document};
|
||||
use crate::extension::LOCAL_QUERY_SOURCE_TYPE;
|
||||
use crate::extension::OnOpened;
|
||||
use crate::util::file::get_file_icon;
|
||||
use crate::util::file::sync_get_file_icon;
|
||||
use std::borrow::Borrow;
|
||||
use std::path::PathBuf;
|
||||
use windows::{
|
||||
Win32::System::{
|
||||
Com::{CLSCTX_INPROC_SERVER, CoCreateInstance},
|
||||
@@ -420,7 +424,7 @@ pub(crate) async fn hits(
|
||||
// "file:C:/Users/desktop.ini" => "C:/Users/desktop.ini"
|
||||
let file_path = &item_url[ITEM_URL_PREFIX_LEN..];
|
||||
|
||||
let icon = get_file_icon(file_path.to_string()).await;
|
||||
let icon = sync_get_file_icon(file_path);
|
||||
let file_path_of_type_path = camino::Utf8Path::new(&file_path);
|
||||
let r#where = file_path_of_type_path
|
||||
.parent()
|
||||
@@ -468,6 +472,85 @@ pub(crate) async fn hits(
|
||||
Ok(hits)
|
||||
}
|
||||
|
||||
pub(crate) fn apply_config(config: &FileSearchConfig) -> Result<(), String> {
|
||||
// To ensure Windows Search indexer index the paths we specified in the
|
||||
// config, we will:
|
||||
//
|
||||
// 1. Add an inclusion rule for every search path to ensure indexer index
|
||||
// them
|
||||
// 2. For the exclude paths, we exclude them from the crawl scope if they
|
||||
// were not included in the scope before we update the scope. Otherwise,
|
||||
// we cannot exclude them as doing that could potentially break other
|
||||
// apps (by removing the indexes they rely on).
|
||||
//
|
||||
// Windows APIs are pretty smart. They won't blindly add an inclusion rule if
|
||||
// the path you are trying to include is already included. The same applies
|
||||
// to exclusion rules as well. Since Windows APIs handle these checks for us,
|
||||
// we don't need to worry about them.
|
||||
|
||||
use crawl_scope_manager::CrawlScopeManager;
|
||||
use crawl_scope_manager::Rule;
|
||||
use crawl_scope_manager::RuleMode;
|
||||
use std::borrow::Cow;
|
||||
|
||||
/// Windows APIs need the path to contain a tailing '\'
|
||||
fn add_tailing_backslash(path: &str) -> Cow<'_, str> {
|
||||
if path.ends_with(r#"\"#) {
|
||||
Cow::Borrowed(path)
|
||||
} else {
|
||||
let mut owned = path.to_string();
|
||||
owned.push_str(r#"\"#);
|
||||
|
||||
Cow::Owned(owned)
|
||||
}
|
||||
}
|
||||
|
||||
let mut manager = CrawlScopeManager::new().map_err(|e| e.to_string())?;
|
||||
|
||||
let search_paths = &config.search_paths;
|
||||
let exclude_paths = &config.exclude_paths;
|
||||
|
||||
// indexes to `exclude_paths` of the paths we need to exclude
|
||||
let mut paths_to_exclude: Vec<usize> = Vec::new();
|
||||
for (idx, exclude_path) in exclude_paths.into_iter().enumerate() {
|
||||
let exclude_path = add_tailing_backslash(&exclude_path);
|
||||
let exclude_path: &str = exclude_path.borrow();
|
||||
|
||||
if !manager
|
||||
.is_path_included(exclude_path)
|
||||
.map_err(|e| e.to_string())?
|
||||
{
|
||||
paths_to_exclude.push(idx);
|
||||
}
|
||||
}
|
||||
|
||||
for search_path in search_paths {
|
||||
let inclusion_rule = Rule {
|
||||
paths: PathBuf::from(add_tailing_backslash(&search_path).into_owned()),
|
||||
mode: RuleMode::Inclusion,
|
||||
};
|
||||
|
||||
manager
|
||||
.add_rule(inclusion_rule)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
for idx in paths_to_exclude {
|
||||
let exclusion_rule = Rule {
|
||||
paths: PathBuf::from(add_tailing_backslash(&exclude_paths[idx]).into_owned()),
|
||||
mode: RuleMode::Exclusion,
|
||||
};
|
||||
|
||||
manager
|
||||
.add_rule(exclusion_rule)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
manager.commit().map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Skip these tests in our CI, they fail with the following error
|
||||
// "SQL is invalid: "0x80041820""
|
||||
//
|
||||
@@ -19,7 +19,7 @@ pub(crate) const PLUGIN_JSON_FILE: &str = r#"
|
||||
{
|
||||
"id": "File Search",
|
||||
"name": "File Search",
|
||||
"platforms": ["macos", "windows"],
|
||||
"platforms": ["macos", "windows", "linux"],
|
||||
"description": "Search files on your system",
|
||||
"icon": "font_Filesearch",
|
||||
"type": "extension"
|
||||
|
||||
@@ -3,10 +3,11 @@
|
||||
pub mod ai_overview;
|
||||
pub mod application;
|
||||
pub mod calculator;
|
||||
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
||||
pub mod file_search;
|
||||
pub mod pizza_engine_runtime;
|
||||
pub mod quick_ai_access;
|
||||
#[cfg(target_os = "macos")]
|
||||
pub mod window_management;
|
||||
|
||||
use super::Extension;
|
||||
use crate::SearchSourceRegistry;
|
||||
@@ -15,6 +16,8 @@ use crate::extension::{
|
||||
ExtensionBundleIdBorrowed, PLUGIN_JSON_FILE_NAME, alter_extension_json_file,
|
||||
};
|
||||
use anyhow::Context;
|
||||
use file_search::config::FileSearchConfig;
|
||||
use file_search::implementation::apply_config as file_search_apply_config;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
@@ -173,17 +176,26 @@ pub(crate) async fn list_built_in_extensions(
|
||||
.await?,
|
||||
);
|
||||
|
||||
built_in_extensions.push(
|
||||
load_built_in_extension(
|
||||
&dir,
|
||||
file_search::EXTENSION_ID,
|
||||
file_search::PLUGIN_JSON_FILE,
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(any(target_os = "macos", target_os = "windows"))] {
|
||||
built_in_extensions.push(
|
||||
load_built_in_extension(
|
||||
&dir,
|
||||
file_search::EXTENSION_ID,
|
||||
file_search::PLUGIN_JSON_FILE,
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
}
|
||||
if #[cfg(target_os = "macos")] {
|
||||
built_in_extensions.push(
|
||||
load_built_in_extension(
|
||||
&dir,
|
||||
window_management::EXTENSION_ID,
|
||||
window_management::PLUGIN_JSON_FILE,
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(built_in_extensions)
|
||||
@@ -212,16 +224,28 @@ pub(super) async fn init_built_in_extension(
|
||||
log::debug!("built-in extension [{}] initialized", extension.id);
|
||||
}
|
||||
|
||||
if extension.id == file_search::EXTENSION_ID {
|
||||
let file_system_search = file_search::FileSearchExtensionSearchSource;
|
||||
search_source_registry
|
||||
.register_source(file_system_search)
|
||||
.await;
|
||||
let file_search_config = FileSearchConfig::get(tauri_app_handle);
|
||||
file_search_apply_config(&file_search_config)?;
|
||||
log::debug!("built-in extension [{}] initialized", extension.id);
|
||||
}
|
||||
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(any(target_os = "macos", target_os = "windows"))] {
|
||||
if extension.id == file_search::EXTENSION_ID {
|
||||
let file_system_search = file_search::FileSearchExtensionSearchSource;
|
||||
search_source_registry
|
||||
.register_source(file_system_search)
|
||||
.await;
|
||||
log::debug!("built-in extension [{}] initialized", extension.id);
|
||||
}
|
||||
}
|
||||
if #[cfg(target_os = "macos")] {
|
||||
if extension.id == window_management::EXTENSION_ID {
|
||||
let file_system_search = window_management::search_source::WindowManagementSearchSource;
|
||||
search_source_registry
|
||||
.register_source(file_system_search)
|
||||
.await;
|
||||
|
||||
window_management::set_up_commands_hotkeys(tauri_app_handle, extension)?;
|
||||
log::debug!("built-in extension [{}] initialized", extension.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -299,19 +323,51 @@ pub(crate) async fn enable_built_in_extension(
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if bundle_id.extension_id == file_search::EXTENSION_ID {
|
||||
let file_system_search = file_search::FileSearchExtensionSearchSource;
|
||||
search_source_registry_tauri_state
|
||||
.register_source(file_system_search)
|
||||
.await;
|
||||
alter_extension_json_file(
|
||||
&get_built_in_extension_directory(tauri_app_handle),
|
||||
bundle_id,
|
||||
update_extension,
|
||||
)?;
|
||||
let file_search_config = FileSearchConfig::get(tauri_app_handle);
|
||||
file_search_apply_config(&file_search_config)?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(any(target_os = "macos", target_os = "windows"))] {
|
||||
if bundle_id.extension_id == file_search::EXTENSION_ID {
|
||||
let file_system_search = file_search::FileSearchExtensionSearchSource;
|
||||
search_source_registry_tauri_state
|
||||
.register_source(file_system_search)
|
||||
.await;
|
||||
alter_extension_json_file(
|
||||
&get_built_in_extension_directory(tauri_app_handle),
|
||||
bundle_id,
|
||||
update_extension,
|
||||
)?;
|
||||
return Ok(());
|
||||
if #[cfg(target_os = "macos")] {
|
||||
if bundle_id.extension_id == window_management::EXTENSION_ID
|
||||
&& bundle_id.sub_extension_id.is_none()
|
||||
{
|
||||
let built_in_extension_dir = get_built_in_extension_directory(tauri_app_handle);
|
||||
|
||||
let file_system_search = window_management::search_source::WindowManagementSearchSource;
|
||||
search_source_registry_tauri_state
|
||||
.register_source(file_system_search)
|
||||
.await;
|
||||
|
||||
let extension =
|
||||
load_extension_from_json_file(&built_in_extension_dir, bundle_id.extension_id)?;
|
||||
window_management::set_up_commands_hotkeys(tauri_app_handle, &extension)?;
|
||||
|
||||
alter_extension_json_file(&built_in_extension_dir, bundle_id, update_extension)?;
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if bundle_id.extension_id == window_management::EXTENSION_ID {
|
||||
if let Some(command_id) = bundle_id.sub_extension_id {
|
||||
let built_in_extension_dir = get_built_in_extension_directory(tauri_app_handle);
|
||||
alter_extension_json_file(&built_in_extension_dir, bundle_id, update_extension)?;
|
||||
|
||||
let extension =
|
||||
load_extension_from_json_file(&built_in_extension_dir, bundle_id.extension_id)?;
|
||||
window_management::set_up_command_hotkey(tauri_app_handle, &extension, command_id)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -387,18 +443,44 @@ pub(crate) async fn disable_built_in_extension(
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if bundle_id.extension_id == file_search::EXTENSION_ID {
|
||||
search_source_registry_tauri_state
|
||||
.remove_source(bundle_id.extension_id)
|
||||
.await;
|
||||
alter_extension_json_file(
|
||||
&get_built_in_extension_directory(tauri_app_handle),
|
||||
bundle_id,
|
||||
update_extension,
|
||||
)?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(any(target_os = "macos", target_os = "windows"))] {
|
||||
if bundle_id.extension_id == file_search::EXTENSION_ID {
|
||||
if #[cfg(target_os = "macos")] {
|
||||
if bundle_id.extension_id == window_management::EXTENSION_ID
|
||||
&& bundle_id.sub_extension_id.is_none()
|
||||
{
|
||||
let built_in_extension_dir = get_built_in_extension_directory(tauri_app_handle);
|
||||
|
||||
search_source_registry_tauri_state
|
||||
.remove_source(bundle_id.extension_id)
|
||||
.await;
|
||||
alter_extension_json_file(
|
||||
&get_built_in_extension_directory(tauri_app_handle),
|
||||
bundle_id,
|
||||
update_extension,
|
||||
)?;
|
||||
return Ok(());
|
||||
alter_extension_json_file(&built_in_extension_dir, bundle_id, update_extension)?;
|
||||
|
||||
let extension =
|
||||
load_extension_from_json_file(&built_in_extension_dir, bundle_id.extension_id)?;
|
||||
window_management::unset_commands_hotkeys(tauri_app_handle, &extension)?;
|
||||
}
|
||||
|
||||
if bundle_id.extension_id == window_management::EXTENSION_ID {
|
||||
if let Some(command_id) = bundle_id.sub_extension_id {
|
||||
let built_in_extension_dir = get_built_in_extension_directory(tauri_app_handle);
|
||||
alter_extension_json_file(&built_in_extension_dir, bundle_id, update_extension)?;
|
||||
|
||||
let extension =
|
||||
load_extension_from_json_file(&built_in_extension_dir, bundle_id.extension_id)?;
|
||||
window_management::unset_command_hotkey(tauri_app_handle, &extension, command_id)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -410,12 +492,32 @@ pub(crate) fn set_built_in_extension_alias(
|
||||
tauri_app_handle: &AppHandle,
|
||||
bundle_id: &ExtensionBundleIdBorrowed<'_>,
|
||||
alias: &str,
|
||||
) {
|
||||
) -> Result<(), String> {
|
||||
if bundle_id.extension_id == application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME {
|
||||
if let Some(app_path) = bundle_id.sub_extension_id {
|
||||
application::set_app_alias(tauri_app_handle, app_path, alias);
|
||||
}
|
||||
}
|
||||
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(target_os = "macos")] {
|
||||
if bundle_id.extension_id == window_management::EXTENSION_ID
|
||||
&& bundle_id.sub_extension_id.is_some()
|
||||
{
|
||||
let update_function = |ext: &mut Extension| {
|
||||
ext.alias = Some(alias.to_string());
|
||||
Ok(())
|
||||
};
|
||||
alter_extension_json_file(
|
||||
&get_built_in_extension_directory(tauri_app_handle),
|
||||
bundle_id,
|
||||
update_function,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn register_built_in_extension_hotkey(
|
||||
@@ -428,6 +530,29 @@ pub(crate) fn register_built_in_extension_hotkey(
|
||||
application::register_app_hotkey(&tauri_app_handle, app_path, hotkey)?;
|
||||
}
|
||||
}
|
||||
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(target_os = "macos")] {
|
||||
let update_function = |ext: &mut Extension| {
|
||||
ext.hotkey = Some(hotkey.into());
|
||||
|
||||
Ok(())
|
||||
};
|
||||
|
||||
if bundle_id.extension_id == window_management::EXTENSION_ID {
|
||||
if let Some(command_id) = bundle_id.sub_extension_id {
|
||||
alter_extension_json_file(
|
||||
&get_built_in_extension_directory(tauri_app_handle),
|
||||
bundle_id,
|
||||
update_function,
|
||||
)?;
|
||||
|
||||
window_management::register_command_hotkey(tauri_app_handle, command_id, hotkey)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -440,6 +565,35 @@ pub(crate) fn unregister_built_in_extension_hotkey(
|
||||
application::unregister_app_hotkey(&tauri_app_handle, app_path)?;
|
||||
}
|
||||
}
|
||||
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(target_os = "macos")] {
|
||||
let update_function = |ext: &mut Extension| {
|
||||
ext.hotkey = None;
|
||||
|
||||
Ok(())
|
||||
};
|
||||
|
||||
if bundle_id.extension_id == window_management::EXTENSION_ID {
|
||||
if let Some(command_id) = bundle_id.sub_extension_id {
|
||||
|
||||
let extension = load_extension_from_json_file(
|
||||
&get_built_in_extension_directory(tauri_app_handle),
|
||||
bundle_id.extension_id,
|
||||
)
|
||||
.unwrap();
|
||||
window_management::unregister_command_hotkey(tauri_app_handle, &extension, command_id)?;
|
||||
alter_extension_json_file(
|
||||
&get_built_in_extension_directory(tauri_app_handle),
|
||||
bundle_id,
|
||||
update_function,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -479,6 +633,8 @@ fn load_extension_from_json_file(
|
||||
Ok(extension)
|
||||
}
|
||||
|
||||
#[allow(unused_macros)] // #[function_name::named] only used on macOS
|
||||
#[function_name::named]
|
||||
pub(crate) async fn is_built_in_extension_enabled(
|
||||
tauri_app_handle: &AppHandle,
|
||||
bundle_id: &ExtensionBundleIdBorrowed<'_>,
|
||||
@@ -524,9 +680,17 @@ pub(crate) async fn is_built_in_extension_enabled(
|
||||
return Ok(extension.enabled);
|
||||
}
|
||||
|
||||
if bundle_id.extension_id == file_search::EXTENSION_ID && bundle_id.sub_extension_id.is_none() {
|
||||
return Ok(search_source_registry_tauri_state
|
||||
.get_source(bundle_id.extension_id)
|
||||
.await
|
||||
.is_some());
|
||||
}
|
||||
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(any(target_os = "macos", target_os = "windows"))] {
|
||||
if bundle_id.extension_id == file_search::EXTENSION_ID
|
||||
if #[cfg(target_os = "macos")] {
|
||||
// Window Management
|
||||
if bundle_id.extension_id == window_management::EXTENSION_ID
|
||||
&& bundle_id.sub_extension_id.is_none()
|
||||
{
|
||||
return Ok(search_source_registry_tauri_state
|
||||
@@ -534,6 +698,25 @@ pub(crate) async fn is_built_in_extension_enabled(
|
||||
.await
|
||||
.is_some());
|
||||
}
|
||||
|
||||
// Window Management commands
|
||||
if bundle_id.extension_id == window_management::EXTENSION_ID
|
||||
&& let Some(command_id) = bundle_id.sub_extension_id
|
||||
{
|
||||
let extension = load_extension_from_json_file(
|
||||
&get_built_in_extension_directory(tauri_app_handle),
|
||||
bundle_id.extension_id,
|
||||
)?;
|
||||
let commands = extension
|
||||
.commands
|
||||
.expect("window management extension has commands");
|
||||
|
||||
let extension = commands.iter().find( |cmd| cmd.id == command_id).unwrap_or_else(|| {
|
||||
panic!("function [{}()] invoked with a Window Management command that does not exist, extension ID [{}] ", function_name!(), command_id)
|
||||
});
|
||||
|
||||
return Ok(extension.enabled);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
134
src-tauri/src/extension/built_in/window_management/actions.rs
Normal file
134
src-tauri/src/extension/built_in/window_management/actions.rs
Normal file
@@ -0,0 +1,134 @@
|
||||
#[derive(Debug, Clone, PartialEq, Copy, Hash, serde::Serialize, serde::Deserialize)]
|
||||
pub enum Action {
|
||||
/// Move the window to fill left half of the screen.
|
||||
TopHalf,
|
||||
/// Move the window to fill bottom half of the screen.
|
||||
BottomHalf,
|
||||
/// Move the window to fill left half of the screen.
|
||||
LeftHalf,
|
||||
/// Move the window to fill right half of the screen.
|
||||
RightHalf,
|
||||
/// Move the window to fill center half of the screen.
|
||||
CenterHalf,
|
||||
|
||||
/// Resize window to the top left quarter of the screen.
|
||||
TopLeftQuarter,
|
||||
/// Resize window to the top right quarter of the screen.
|
||||
TopRightQuarter,
|
||||
/// Resize window to the bottom left quarter of the screen.
|
||||
BottomLeftQuarter,
|
||||
/// Resize window to the bottom right quarter of the screen.
|
||||
BottomRightQuarter,
|
||||
|
||||
/// Resize window to the top left sixth of the screen.
|
||||
TopLeftSixth,
|
||||
/// Resize window to the top center sixth of the screen.
|
||||
TopCenterSixth,
|
||||
/// Resize window to the top right sixth of the screen.
|
||||
TopRightSixth,
|
||||
/// Resize window to the bottom left sixth of the screen.
|
||||
BottomLeftSixth,
|
||||
/// Resize window to the bottom center sixth of the screen.
|
||||
BottomCenterSixth,
|
||||
/// Resize window to the bottom right sixth of the screen.
|
||||
BottomRightSixth,
|
||||
|
||||
/// Resize window to the top third of the screen.
|
||||
TopThird,
|
||||
/// Resize window to the middle third of the screen.
|
||||
MiddleThird,
|
||||
/// Resize window to the bottom third of the screen.
|
||||
BottomThird,
|
||||
|
||||
/// Center window in the screen.
|
||||
Center,
|
||||
|
||||
/// Resize window to the first fourth of the screen.
|
||||
FirstFourth,
|
||||
/// Resize window to the second fourth of the screen.
|
||||
SecondFourth,
|
||||
/// Resize window to the third fourth of the screen.
|
||||
ThirdFourth,
|
||||
/// Resize window to the last fourth of the screen.
|
||||
LastFourth,
|
||||
|
||||
/// Resize window to the first third of the screen.
|
||||
FirstThird,
|
||||
/// Resize window to the center third of the screen.
|
||||
CenterThird,
|
||||
/// Resize window to the last third of the screen.
|
||||
LastThird,
|
||||
|
||||
/// Resize window to the first two thirds of the screen.
|
||||
FirstTwoThirds,
|
||||
/// Resize window to the center two thirds of the screen.
|
||||
CenterTwoThirds,
|
||||
/// Resize window to the last two thirds of the screen.
|
||||
LastTwoThirds,
|
||||
|
||||
/// Resize window to the first three fourths of the screen.
|
||||
FirstThreeFourths,
|
||||
/// Resize window to the center three fourths of the screen.
|
||||
CenterThreeFourths,
|
||||
/// Resize window to the last three fourths of the screen.
|
||||
LastThreeFourths,
|
||||
|
||||
/// Resize window to the top three fourths of the screen.
|
||||
TopThreeFourths,
|
||||
/// Resize window to the bottom three fourths of the screen.
|
||||
BottomThreeFourths,
|
||||
|
||||
/// Resize window to the top two thirds of the screen.
|
||||
TopTwoThirds,
|
||||
/// Resize window to the bottom two thirds of the screen.
|
||||
BottomTwoThirds,
|
||||
/// Resize window to the top center two thirds of the screen.
|
||||
TopCenterTwoThirds,
|
||||
|
||||
/// Resize window to the top first fourth of the screen.
|
||||
TopFirstFourth,
|
||||
/// Resize window to the top second fourth of the screen.
|
||||
TopSecondFourth,
|
||||
/// Resize window to the top third fourth of the screen.
|
||||
TopThirdFourth,
|
||||
/// Resize window to the top last fourth of the screen.
|
||||
TopLastFourth,
|
||||
|
||||
/// Increase the window until it reaches the screen size.
|
||||
MakeLarger,
|
||||
/// Decrease the window until it reaches its minimal size.
|
||||
MakeSmaller,
|
||||
|
||||
/// Maximize window to almost fit the screen.
|
||||
AlmostMaximize,
|
||||
/// Maximize window to fit the screen.
|
||||
Maximize,
|
||||
/// Maximize width of window to fit the screen.
|
||||
MaximizeWidth,
|
||||
/// Maximize height of window to fit the screen.
|
||||
MaximizeHeight,
|
||||
|
||||
/// Move window to the top edge of the screen.
|
||||
MoveUp,
|
||||
/// Move window to the bottom of the screen.
|
||||
MoveDown,
|
||||
/// Move window to the left edge of the screen.
|
||||
MoveLeft,
|
||||
/// Move window to the right edge of the screen.
|
||||
MoveRight,
|
||||
|
||||
/// Move window to the next desktop.
|
||||
NextDesktop,
|
||||
/// Move window to the previous desktop.
|
||||
PreviousDesktop,
|
||||
/// Move window to the next display.
|
||||
NextDisplay,
|
||||
/// Move window to the previous display.
|
||||
PreviousDisplay,
|
||||
|
||||
/// Restore window to its last position.
|
||||
Restore,
|
||||
|
||||
/// Toggle fullscreen mode.
|
||||
ToggleFullscreen,
|
||||
}
|
||||
@@ -0,0 +1,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,10 +1,14 @@
|
||||
pub(crate) mod api;
|
||||
pub(crate) mod built_in;
|
||||
pub(crate) mod third_party;
|
||||
|
||||
use crate::common::document::ExtensionOnOpened;
|
||||
use crate::common::document::ExtensionOnOpenedType;
|
||||
use crate::common::document::OnOpened;
|
||||
use crate::common::register::SearchSourceRegistry;
|
||||
use crate::util::platform::Platform;
|
||||
use anyhow::Context;
|
||||
use bitflags::bitflags;
|
||||
use borrowme::{Borrow, ToOwned};
|
||||
use derive_more::Display;
|
||||
use indexmap::IndexMap;
|
||||
@@ -76,11 +80,14 @@ pub struct Extension {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
quicklink: Option<Quicklink>,
|
||||
|
||||
// If this extension is of type Group or Extension, then it behaves like a
|
||||
// directory, i.e., it could contain sub items.
|
||||
/*
|
||||
* If this extension is of type Group or Extension, then it behaves like a
|
||||
* directory, i.e., it could contain sub items.
|
||||
*/
|
||||
commands: Option<Vec<Extension>>,
|
||||
scripts: Option<Vec<Extension>>,
|
||||
quicklinks: Option<Vec<Extension>>,
|
||||
views: Option<Vec<Extension>>,
|
||||
|
||||
/// The alias of the extension.
|
||||
///
|
||||
@@ -99,14 +106,39 @@ pub struct Extension {
|
||||
|
||||
/// Extension settings
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
settings: Option<Json>,
|
||||
settings: Option<ExtensionSettings>,
|
||||
|
||||
// We do not care about these fields, just take it regardless of what it is.
|
||||
/// For View extensions, path to the HTML file/page that coco will load
|
||||
/// and render. Otherwise, `None`.
|
||||
page: Option<String>,
|
||||
|
||||
ui: Option<ViewExtensionUISettings>,
|
||||
|
||||
/// Permission that this extension requires.
|
||||
permission: Option<ExtensionPermission>,
|
||||
|
||||
/*
|
||||
* The following fields are currently useless to us but are needed by our
|
||||
* extension store.
|
||||
*
|
||||
* Since we do not use them, just accept them regardless of what they are.
|
||||
*/
|
||||
screenshots: Option<Json>,
|
||||
url: Option<Json>,
|
||||
version: Option<Json>,
|
||||
}
|
||||
|
||||
/// 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.
|
||||
#[derive(Debug, Deserialize, Serialize, PartialEq, Clone)]
|
||||
pub(crate) struct ExtensionBundleId {
|
||||
@@ -164,37 +196,62 @@ impl<'ext> PartialEq<ExtensionBundleId> for ExtensionBundleIdBorrowed<'ext> {
|
||||
}
|
||||
|
||||
impl Extension {
|
||||
/// WARNING: the bundle ID returned from this function always has its `sub_extension_id`
|
||||
/// set to `None`, this may not be what you want.
|
||||
pub(crate) fn bundle_id_borrowed(&self) -> ExtensionBundleIdBorrowed<'_> {
|
||||
ExtensionBundleIdBorrowed {
|
||||
developer: self.developer.as_deref(),
|
||||
extension_id: &self.id,
|
||||
sub_extension_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this extension could be searched.
|
||||
pub(crate) fn searchable(&self) -> bool {
|
||||
self.on_opened().is_some()
|
||||
}
|
||||
|
||||
/// Return what will happen when we open this extension.
|
||||
///
|
||||
/// `None` if it cannot be opened.
|
||||
pub(crate) fn on_opened(&self) -> Option<OnOpened> {
|
||||
let settings = self.settings.clone();
|
||||
let permission = self.permission.clone();
|
||||
|
||||
match self.r#type {
|
||||
ExtensionType::Group => None,
|
||||
ExtensionType::Extension => None,
|
||||
ExtensionType::Command => Some(OnOpened::Command {
|
||||
action: self.action.clone().unwrap_or_else(|| {
|
||||
panic!(
|
||||
"Command extension [{}]'s [action] field is not set, something wrong with your extension validity check", self.id
|
||||
)
|
||||
}),
|
||||
}),
|
||||
ExtensionType::Application => Some(OnOpened::Application {
|
||||
app_path: self.id.clone(),
|
||||
}),
|
||||
// This function, at the time of writing this comment, is primarily
|
||||
// used by third-party extensions.
|
||||
//
|
||||
// Built-in extensions don't use this as they are technically not
|
||||
// "struct Extension"s. Typically, they directly construct a
|
||||
// "struct Document" from their own type.
|
||||
ExtensionType::Calculator => unreachable!("this is handled by frontend"),
|
||||
ExtensionType::AiExtension => unreachable!(
|
||||
"currently, all AI extensions we have are non-searchable, so we won't open them"
|
||||
),
|
||||
ExtensionType::Application => {
|
||||
// We can have a impl like:
|
||||
//
|
||||
// Some(OnOpened::Application { app_path: self.id.clone() })
|
||||
//
|
||||
// but it won't be used.
|
||||
|
||||
unreachable!(
|
||||
"Applications are not \"struct Extension\" under the hood, they won't call this method"
|
||||
)
|
||||
}
|
||||
|
||||
// These 2 types of extensions cannot be opened
|
||||
ExtensionType::Group => return None,
|
||||
ExtensionType::Extension => return None,
|
||||
|
||||
ExtensionType::Command => {
|
||||
let ty = ExtensionOnOpenedType::Command {
|
||||
action: self.action.clone().unwrap_or_else(|| {
|
||||
panic!(
|
||||
"Command extension [{}]'s [action] field is not set, something wrong with your extension validity check", self.id
|
||||
)
|
||||
}),
|
||||
};
|
||||
|
||||
let extension_on_opened = ExtensionOnOpened {
|
||||
ty,
|
||||
settings,
|
||||
permission,
|
||||
};
|
||||
|
||||
Some(OnOpened::Extension(extension_on_opened))
|
||||
}
|
||||
ExtensionType::Quicklink => {
|
||||
let quicklink = self.quicklink.clone().unwrap_or_else(|| {
|
||||
panic!(
|
||||
@@ -202,15 +259,37 @@ impl Extension {
|
||||
)
|
||||
});
|
||||
|
||||
Some(OnOpened::Quicklink{
|
||||
link: quicklink.link,
|
||||
open_with: quicklink.open_with,
|
||||
})
|
||||
let ty = ExtensionOnOpenedType::Quicklink {
|
||||
link: quicklink.link,
|
||||
open_with: quicklink.open_with,
|
||||
};
|
||||
|
||||
let extension_on_opened = ExtensionOnOpened {
|
||||
ty,
|
||||
settings,
|
||||
permission,
|
||||
};
|
||||
|
||||
Some(OnOpened::Extension(extension_on_opened))
|
||||
}
|
||||
ExtensionType::Script => todo!("not supported yet"),
|
||||
ExtensionType::Setting => todo!("not supported yet"),
|
||||
ExtensionType::Calculator => None,
|
||||
ExtensionType::AiExtension => None,
|
||||
ExtensionType::View => {
|
||||
let page = self.page.as_ref().unwrap_or_else(|| {
|
||||
panic!("View extension [{}]'s [page] field is not set, something wrong with your extension validity check", self.id);
|
||||
}).clone();
|
||||
let ui = self.ui.clone();
|
||||
|
||||
let extension_on_opened_type = ExtensionOnOpenedType::View { 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,6 +313,11 @@ impl Extension {
|
||||
return Some(sub_ext);
|
||||
}
|
||||
}
|
||||
if let Some(ref views) = self.views {
|
||||
if let Some(sub_ext) = views.iter().find(|view| view.id == sub_extension_id) {
|
||||
return Some(sub_ext);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
@@ -264,6 +348,11 @@ impl Extension {
|
||||
return Some(sub_ext);
|
||||
}
|
||||
}
|
||||
if let Some(ref mut views) = self.views {
|
||||
if let Some(sub_ext) = views.iter_mut().find(|view| view.id == sub_extension_id) {
|
||||
return Some(sub_ext);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
@@ -341,7 +430,7 @@ impl QuicklinkLink {
|
||||
/// if any.
|
||||
pub(crate) fn concatenate_url(
|
||||
&self,
|
||||
user_supplied_args: &Option<HashMap<String, String>>,
|
||||
user_supplied_args: &Option<HashMap<String, Json>>,
|
||||
) -> String {
|
||||
let mut out = String::new();
|
||||
for component in self.components.iter() {
|
||||
@@ -353,20 +442,23 @@ impl QuicklinkLink {
|
||||
argument_name,
|
||||
default,
|
||||
} => {
|
||||
let opt_argument_value = {
|
||||
let opt_argument_value: Option<&str> = {
|
||||
let user_supplied_arg = user_supplied_args
|
||||
.as_ref()
|
||||
.and_then(|map| map.get(argument_name.as_str()));
|
||||
|
||||
if user_supplied_arg.is_some() {
|
||||
user_supplied_arg
|
||||
user_supplied_arg.map(|json| {
|
||||
json.as_str()
|
||||
.expect("quicklink should provide string arguments")
|
||||
})
|
||||
} else {
|
||||
default.as_ref()
|
||||
default.as_deref()
|
||||
}
|
||||
};
|
||||
|
||||
let argument_value_str = match opt_argument_value {
|
||||
Some(str) => str.as_str(),
|
||||
Some(str) => str,
|
||||
// None => an empty string
|
||||
None => "",
|
||||
};
|
||||
@@ -473,6 +565,8 @@ pub enum ExtensionType {
|
||||
Calculator,
|
||||
#[display("AI Extension")]
|
||||
AiExtension,
|
||||
#[display("View")]
|
||||
View,
|
||||
}
|
||||
|
||||
impl ExtensionType {
|
||||
@@ -504,6 +598,9 @@ fn filter_out_extensions(
|
||||
if let Some(ref mut quicklinks) = extension.quicklinks {
|
||||
quicklinks.retain(|link| link.enabled);
|
||||
}
|
||||
if let Some(ref mut views) = extension.views {
|
||||
views.retain(|link| link.enabled);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -532,6 +629,9 @@ fn filter_out_extensions(
|
||||
if let Some(ref mut quicklinks) = extension.quicklinks {
|
||||
quicklinks.retain(|link| link.r#type == extension_type);
|
||||
}
|
||||
if let Some(ref mut views) = extension.views {
|
||||
views.retain(|link| link.r#type == extension_type);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -582,15 +682,14 @@ fn filter_out_extensions(
|
||||
if let Some(ref mut quicklinks) = extension.quicklinks {
|
||||
quicklinks.retain(&match_closure);
|
||||
}
|
||||
if let Some(ref mut views) = extension.views {
|
||||
views.retain(&match_closure);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Return value:
|
||||
///
|
||||
/// * boolean: indicates if we found any invalid extensions
|
||||
/// * Vec<Extension>: loaded extensions
|
||||
#[tauri::command]
|
||||
pub(crate) async fn list_extensions(
|
||||
tauri_app_handle: AppHandle,
|
||||
@@ -598,16 +697,11 @@ pub(crate) async fn list_extensions(
|
||||
extension_type: Option<ExtensionType>,
|
||||
list_enabled: bool,
|
||||
) -> Result<Vec<Extension>, String> {
|
||||
log::trace!("loading extensions");
|
||||
|
||||
let third_party_dir = third_party::get_third_party_extension_directory(&tauri_app_handle);
|
||||
if !third_party_dir.try_exists().map_err(|e| e.to_string())? {
|
||||
tokio::fs::create_dir_all(&third_party_dir)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
let mut third_party_extensions =
|
||||
third_party::list_third_party_extensions(&third_party_dir).await?;
|
||||
let mut third_party_extensions = third_party::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE
|
||||
.get()
|
||||
.expect("global third party extension search source not set")
|
||||
.extensions_snapshot()
|
||||
.await;
|
||||
|
||||
let built_in_extensions = built_in::list_built_in_extensions(&tauri_app_handle).await?;
|
||||
|
||||
@@ -661,31 +755,54 @@ pub(crate) async fn list_extensions(
|
||||
Ok(extensions)
|
||||
}
|
||||
|
||||
pub(crate) async fn init_extensions(
|
||||
tauri_app_handle: AppHandle,
|
||||
mut extensions: Vec<Extension>,
|
||||
) -> Result<(), String> {
|
||||
/// Initialize all the things that are related to extensions.
|
||||
pub(crate) async fn init_extensions(tauri_app_handle: &AppHandle) -> Result<(), String> {
|
||||
log::trace!("initializing extensions");
|
||||
|
||||
let search_source_registry_tauri_state = tauri_app_handle.state::<SearchSourceRegistry>();
|
||||
|
||||
// Third-party extensions
|
||||
//
|
||||
// 1. Init the global search source variable
|
||||
// 2. Init the extensions in search source
|
||||
// 3. Register the search source
|
||||
let third_party_dir = third_party::get_third_party_extension_directory(&tauri_app_handle);
|
||||
if !third_party_dir.try_exists().map_err(|e| e.to_string())? {
|
||||
tokio::fs::create_dir_all(&third_party_dir)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
let extensions =
|
||||
third_party::load_third_party_extensions_from_directory(&third_party_dir).await?;
|
||||
let search_source = third_party::ThirdPartyExtensionsSearchSource::new(extensions);
|
||||
search_source.init(&tauri_app_handle).await.unwrap();
|
||||
THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE
|
||||
.set(search_source.clone())
|
||||
.unwrap_or_else(|_already_set| {
|
||||
panic!(
|
||||
"while trying to set the global third party extension search source variable {}, we found it is already set, which should not happen",
|
||||
"THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE"
|
||||
)
|
||||
});
|
||||
search_source_registry_tauri_state
|
||||
.register_source(search_source)
|
||||
.await;
|
||||
|
||||
// Extension store
|
||||
search_source_registry_tauri_state
|
||||
.register_source(third_party::install::store::ExtensionStore)
|
||||
.await;
|
||||
|
||||
// Built-in extensions
|
||||
|
||||
// Built-in extension: Application
|
||||
built_in::application::ApplicationSearchSource::prepare_index_and_store(
|
||||
tauri_app_handle.clone(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// extension store
|
||||
search_source_registry_tauri_state
|
||||
.register_source(third_party::install::store::ExtensionStore)
|
||||
.await;
|
||||
|
||||
// Init the built-in enabled extensions
|
||||
for built_in_extension in extensions
|
||||
.extract_if(.., |ext| {
|
||||
built_in::is_extension_built_in(&ext.bundle_id_borrowed())
|
||||
})
|
||||
.filter(|ext| ext.enabled)
|
||||
{
|
||||
let built_in_extensions = built_in::list_built_in_extensions(&tauri_app_handle).await?;
|
||||
for built_in_extension in built_in_extensions.iter().filter(|ext| ext.enabled) {
|
||||
built_in::init_built_in_extension(
|
||||
&tauri_app_handle,
|
||||
&built_in_extension,
|
||||
@@ -694,18 +811,6 @@ pub(crate) async fn init_extensions(
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Now the third-party extensions
|
||||
let third_party_search_source = third_party::ThirdPartyExtensionsSearchSource::new(extensions);
|
||||
third_party_search_source.init(&tauri_app_handle).await?;
|
||||
let third_party_search_source_clone = third_party_search_source.clone();
|
||||
// Set the global search source so that we can access it in `#[tauri::command]`s
|
||||
// ignore the result because this function will be invoked twice, which
|
||||
// means this global variable will be set twice.
|
||||
let _ = THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE.set(third_party_search_source_clone);
|
||||
search_source_registry_tauri_state
|
||||
.register_source(third_party_search_source)
|
||||
.await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -747,7 +852,7 @@ pub(crate) async fn set_extension_alias(
|
||||
let bundle_id_borrowed = bundle_id.borrow();
|
||||
|
||||
if built_in::is_extension_built_in(&bundle_id_borrowed) {
|
||||
built_in::set_built_in_extension_alias(&tauri_app_handle, &bundle_id_borrowed, &alias);
|
||||
built_in::set_built_in_extension_alias(&tauri_app_handle, &bundle_id_borrowed, &alias)?;
|
||||
return Ok(());
|
||||
}
|
||||
third_party::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE.get().expect("global third party search source not set, looks like init_extensions() has not been executed").set_extension_alias(&tauri_app_handle, &bundle_id_borrowed, &alias).await
|
||||
@@ -854,6 +959,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(())
|
||||
}
|
||||
|
||||
@@ -906,6 +1068,14 @@ fn alter_extension_json_file(
|
||||
}
|
||||
}
|
||||
|
||||
// Search in views
|
||||
if let Some(ref mut views) = root_extension.views {
|
||||
if let Some(view) = views.iter_mut().find(|v| v.id == sub_extension_id) {
|
||||
how(view)?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
Err(format!(
|
||||
"extension [{:?}] not found in {:?}",
|
||||
bundle_id, root_extension
|
||||
@@ -1078,6 +1248,126 @@ fn parse_dynamic_placeholder(content: &str) -> Result<QuicklinkLinkComponent, St
|
||||
})
|
||||
}
|
||||
|
||||
/// Built-in extension settings
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub(crate) struct ExtensionSettings {
|
||||
/// If set, Coco main window would hide before opening this document/e
|
||||
pub(crate) hide_before_open: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub(crate) struct ExtensionPermission {
|
||||
fs: Option<Vec<ExtensionFileSystemPermission>>,
|
||||
http: Option<Vec<ExtensionHttpPermission>>,
|
||||
api: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub(crate) struct ExtensionFileSystemPermission {
|
||||
pub(crate) path: String,
|
||||
pub(crate) access: FileSystemAccess,
|
||||
}
|
||||
|
||||
bitflags! {
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub(crate) struct FileSystemAccess: u8 {
|
||||
const READ = 0b00000001;
|
||||
const WRITE = 0b00000010;
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for FileSystemAccess {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
let mut access_vec = Vec::new();
|
||||
if self.contains(FileSystemAccess::READ) {
|
||||
access_vec.push("read");
|
||||
}
|
||||
if self.contains(FileSystemAccess::WRITE) {
|
||||
access_vec.push("write");
|
||||
}
|
||||
access_vec.serialize(serializer)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for FileSystemAccess {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let access_vec: Vec<String> = Vec::deserialize(deserializer)?;
|
||||
let mut access = FileSystemAccess::empty();
|
||||
|
||||
for access_type in access_vec {
|
||||
match access_type.as_str() {
|
||||
"read" => access |= FileSystemAccess::READ,
|
||||
"write" => access |= FileSystemAccess::WRITE,
|
||||
_ => {
|
||||
return Err(serde::de::Error::unknown_variant(
|
||||
access_type.as_str(),
|
||||
&["read", "write"],
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(access)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub(crate) struct ExtensionHttpPermission {
|
||||
pub(crate) host: String,
|
||||
}
|
||||
|
||||
/// Calculates a similarity score between a query and a text, aiming for a [0, 1] range.
|
||||
/// Assumes query and text are already lowercased.
|
||||
///
|
||||
/// Used in extension_to_hit().
|
||||
fn calculate_text_similarity(query: &str, text: &str) -> Option<f64> {
|
||||
if query.is_empty() || text.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if text == query {
|
||||
return Some(1.0); // Perfect match
|
||||
}
|
||||
|
||||
let query_len = query.len() as f64;
|
||||
let text_len = text.len() as f64;
|
||||
let ratio = query_len / text_len;
|
||||
let mut score: f64 = 0.0;
|
||||
|
||||
// Case 1: Text starts with the query (prefix match)
|
||||
// Score: base 0.5, bonus up to 0.4 for how much of `text` is covered by `query`. Max 0.9.
|
||||
if text.starts_with(query) {
|
||||
score = score.max(0.5 + 0.4 * ratio);
|
||||
}
|
||||
|
||||
// Case 2: Text contains the query (substring match, not necessarily prefix)
|
||||
// Score: base 0.3, bonus up to 0.3. Max 0.6.
|
||||
// `score.max` ensures that if it's both a prefix and contains, the higher score (prefix) is taken.
|
||||
if text.contains(query) {
|
||||
score = score.max(0.3 + 0.3 * ratio);
|
||||
}
|
||||
|
||||
// Case 3: Fallback for "all query characters exist in text" (order-independent)
|
||||
if score < 0.2 {
|
||||
if query.chars().all(|c_q| text.contains(c_q)) {
|
||||
score = score.max(0.15); // Fixed low score for this weaker match type
|
||||
}
|
||||
}
|
||||
|
||||
if score > 0.0 {
|
||||
// Cap non-perfect matches slightly below 1.0 to make perfect (1.0) distinct.
|
||||
Some(score.min(0.95))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1476,7 +1766,7 @@ mod tests {
|
||||
],
|
||||
};
|
||||
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));
|
||||
assert_eq!(result, "https://www.google.com/search?q=");
|
||||
}
|
||||
@@ -1513,7 +1803,7 @@ mod tests {
|
||||
],
|
||||
};
|
||||
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));
|
||||
assert_eq!(result, "https://www.google.com/search?q=rust");
|
||||
}
|
||||
@@ -1536,7 +1826,7 @@ mod tests {
|
||||
],
|
||||
};
|
||||
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));
|
||||
assert_eq!(result, "https://www.google.com/search?q=python");
|
||||
}
|
||||
@@ -1548,4 +1838,234 @@ mod tests {
|
||||
let result = link.concatenate_url(&None);
|
||||
assert_eq!(result, "");
|
||||
}
|
||||
|
||||
// Helper function for approximate floating point comparison
|
||||
fn approx_eq(a: f64, b: f64) -> bool {
|
||||
(a - b).abs() < 1e-10
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_strings() {
|
||||
assert_eq!(calculate_text_similarity("", "text"), None);
|
||||
assert_eq!(calculate_text_similarity("query", ""), None);
|
||||
assert_eq!(calculate_text_similarity("", ""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_perfect_match() {
|
||||
assert_eq!(calculate_text_similarity("text", "text"), Some(1.0));
|
||||
assert_eq!(calculate_text_similarity("a", "a"), Some(1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_prefix_match() {
|
||||
// For "te" and "text":
|
||||
// score = 0.5 + 0.4 * (2/4) = 0.5 + 0.2 = 0.7
|
||||
let score = calculate_text_similarity("te", "text").unwrap();
|
||||
assert!(approx_eq(score, 0.7));
|
||||
|
||||
// For "tex" and "text":
|
||||
// score = 0.5 + 0.4 * (3/4) = 0.5 + 0.3 = 0.8
|
||||
let score = calculate_text_similarity("tex", "text").unwrap();
|
||||
assert!(approx_eq(score, 0.8));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_substring_match() {
|
||||
// For "ex" and "text":
|
||||
// score = 0.3 + 0.3 * (2/4) = 0.3 + 0.15 = 0.45
|
||||
let score = calculate_text_similarity("ex", "text").unwrap();
|
||||
assert!(approx_eq(score, 0.45));
|
||||
|
||||
// Prefix should score higher than substring
|
||||
assert!(
|
||||
calculate_text_similarity("te", "text").unwrap()
|
||||
> calculate_text_similarity("ex", "text").unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_character_presence() {
|
||||
// Characters present but not in sequence
|
||||
// "tac" in "contact" - not a substring, but all chars exist
|
||||
let score = calculate_text_similarity("tac", "contact").unwrap();
|
||||
assert!(approx_eq(0.3 + 0.3 * (3.0 / 7.0), score));
|
||||
|
||||
assert!(calculate_text_similarity("ac", "contact").is_some());
|
||||
|
||||
// Should not apply if some characters are missing
|
||||
assert_eq!(calculate_text_similarity("xyz", "contact"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_combined_scenarios() {
|
||||
// Test that character presence fallback doesn't override higher scores
|
||||
// "tex" is a prefix of "text" with score 0.8
|
||||
let score = calculate_text_similarity("tex", "text").unwrap();
|
||||
assert!(approx_eq(score, 0.8));
|
||||
|
||||
// Test a case where the characters exist but it's already a substring
|
||||
// "act" is a substring of "contact" with score > 0.2, so fallback won't apply
|
||||
let expected_score = 0.3 + 0.3 * (3.0 / 7.0);
|
||||
let actual_score = calculate_text_similarity("act", "contact").unwrap();
|
||||
assert!(approx_eq(actual_score, expected_score));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_similarity() {
|
||||
assert_eq!(calculate_text_similarity("xyz", "test"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_score_capping() {
|
||||
// Use a long query that's a prefix of a slightly longer text
|
||||
let long_text = "abcdefghijklmnopqrstuvwxyz";
|
||||
let long_prefix = "abcdefghijklmnopqrstuvwxy"; // All but last letter
|
||||
|
||||
// Expected score would be 0.5 + 0.4 * (25/26) = 0.5 + 0.385 = 0.885
|
||||
let expected_score = 0.5 + 0.4 * (25.0 / 26.0);
|
||||
let actual_score = calculate_text_similarity(long_prefix, long_text).unwrap();
|
||||
assert!(approx_eq(actual_score, expected_score));
|
||||
|
||||
// Verify that non-perfect matches are capped at 0.95
|
||||
assert!(calculate_text_similarity("almost", "almost perfect").unwrap() <= 0.95);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filesystem_access_serialize_empty() {
|
||||
let access = FileSystemAccess::empty();
|
||||
let serialized = serde_json::to_string(&access).unwrap();
|
||||
assert_eq!(serialized, "[]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filesystem_access_serialize_read_only() {
|
||||
let access = FileSystemAccess::READ;
|
||||
let serialized = serde_json::to_string(&access).unwrap();
|
||||
assert_eq!(serialized, r#"["read"]"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filesystem_access_serialize_write_only() {
|
||||
let access = FileSystemAccess::WRITE;
|
||||
let serialized = serde_json::to_string(&access).unwrap();
|
||||
assert_eq!(serialized, r#"["write"]"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filesystem_access_serialize_read_write() {
|
||||
let access = FileSystemAccess::READ | FileSystemAccess::WRITE;
|
||||
let serialized = serde_json::to_string(&access).unwrap();
|
||||
// The order should be consistent based on our implementation (read first, then write)
|
||||
assert_eq!(serialized, r#"["read","write"]"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filesystem_access_deserialize_empty() {
|
||||
let json = "[]";
|
||||
let access: FileSystemAccess = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(access, FileSystemAccess::empty());
|
||||
assert!(!access.contains(FileSystemAccess::READ));
|
||||
assert!(!access.contains(FileSystemAccess::WRITE));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filesystem_access_deserialize_read_only() {
|
||||
let json = r#"["read"]"#;
|
||||
let access: FileSystemAccess = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(access, FileSystemAccess::READ);
|
||||
assert!(access.contains(FileSystemAccess::READ));
|
||||
assert!(!access.contains(FileSystemAccess::WRITE));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filesystem_access_deserialize_write_only() {
|
||||
let json = r#"["write"]"#;
|
||||
let access: FileSystemAccess = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(access, FileSystemAccess::WRITE);
|
||||
assert!(!access.contains(FileSystemAccess::READ));
|
||||
assert!(access.contains(FileSystemAccess::WRITE));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filesystem_access_deserialize_read_write() {
|
||||
let json = r#"["read", "write"]"#;
|
||||
let access: FileSystemAccess = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(access, FileSystemAccess::READ | FileSystemAccess::WRITE);
|
||||
assert!(access.contains(FileSystemAccess::READ));
|
||||
assert!(access.contains(FileSystemAccess::WRITE));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filesystem_access_deserialize_write_read_order() {
|
||||
// Test that order doesn't matter during deserialization
|
||||
let json = r#"["write", "read"]"#;
|
||||
let access: FileSystemAccess = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(access, FileSystemAccess::READ | FileSystemAccess::WRITE);
|
||||
assert!(access.contains(FileSystemAccess::READ));
|
||||
assert!(access.contains(FileSystemAccess::WRITE));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filesystem_access_deserialize_duplicate_values() {
|
||||
// Test that duplicate values don't cause issues
|
||||
let json = r#"["read", "read", "write"]"#;
|
||||
let access: FileSystemAccess = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(access, FileSystemAccess::READ | FileSystemAccess::WRITE);
|
||||
assert!(access.contains(FileSystemAccess::READ));
|
||||
assert!(access.contains(FileSystemAccess::WRITE));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filesystem_access_deserialize_invalid_value() {
|
||||
let json = r#"["invalid"]"#;
|
||||
let result: Result<FileSystemAccess, _> = serde_json::from_str(json);
|
||||
assert!(result.is_err());
|
||||
|
||||
let error_msg = result.unwrap_err().to_string();
|
||||
assert!(error_msg.contains("invalid"));
|
||||
assert!(error_msg.contains("read") && error_msg.contains("write"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filesystem_access_deserialize_mixed_valid_invalid() {
|
||||
let json = r#"["read", "invalid", "write"]"#;
|
||||
let result: Result<FileSystemAccess, _> = serde_json::from_str(json);
|
||||
assert!(result.is_err());
|
||||
|
||||
let error_msg = result.unwrap_err().to_string();
|
||||
assert!(error_msg.contains("invalid"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filesystem_access_round_trip_empty() {
|
||||
let original = FileSystemAccess::empty();
|
||||
let serialized = serde_json::to_string(&original).unwrap();
|
||||
let deserialized: FileSystemAccess = serde_json::from_str(&serialized).unwrap();
|
||||
assert_eq!(original, deserialized);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filesystem_access_round_trip_read() {
|
||||
let original = FileSystemAccess::READ;
|
||||
let serialized = serde_json::to_string(&original).unwrap();
|
||||
let deserialized: FileSystemAccess = serde_json::from_str(&serialized).unwrap();
|
||||
assert_eq!(original, deserialized);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filesystem_access_round_trip_write() {
|
||||
let original = FileSystemAccess::WRITE;
|
||||
let serialized = serde_json::to_string(&original).unwrap();
|
||||
let deserialized: FileSystemAccess = serde_json::from_str(&serialized).unwrap();
|
||||
assert_eq!(original, deserialized);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filesystem_access_round_trip_read_write() {
|
||||
let original = FileSystemAccess::READ | FileSystemAccess::WRITE;
|
||||
let serialized = serde_json::to_string(&original).unwrap();
|
||||
let deserialized: FileSystemAccess = serde_json::from_str(&serialized).unwrap();
|
||||
assert_eq!(original, deserialized);
|
||||
}
|
||||
}
|
||||
|
||||
122
src-tauri/src/extension/third_party/check.rs
vendored
122
src-tauri/src/extension/third_party/check.rs
vendored
@@ -47,7 +47,11 @@ pub(crate) fn general_check(extension: &Extension) -> Result<(), String> {
|
||||
Some(ref v) => v.as_slice(),
|
||||
None => &[],
|
||||
};
|
||||
let sub_extensions = [commands, scripts, quicklinks].concat();
|
||||
let views = match extension.views {
|
||||
Some(ref v) => v.as_slice(),
|
||||
None => &[],
|
||||
};
|
||||
let sub_extensions = [commands, scripts, quicklinks, views].concat();
|
||||
let mut sub_extension_ids = HashSet::new();
|
||||
|
||||
for sub_extension in sub_extensions.iter() {
|
||||
@@ -93,7 +97,10 @@ fn check_main_extension_only(extension: &Extension) -> Result<(), String> {
|
||||
}
|
||||
}
|
||||
|
||||
if extension.commands.is_some() || extension.scripts.is_some() || extension.quicklinks.is_some()
|
||||
if extension.commands.is_some()
|
||||
|| extension.scripts.is_some()
|
||||
|| extension.quicklinks.is_some()
|
||||
|| extension.views.is_some()
|
||||
{
|
||||
if extension.r#type != ExtensionType::Group && extension.r#type != ExtensionType::Extension
|
||||
{
|
||||
@@ -104,6 +111,16 @@ fn check_main_extension_only(extension: &Extension) -> Result<(), String> {
|
||||
}
|
||||
}
|
||||
|
||||
if extension.settings.is_some() {
|
||||
// Sub-extensions are all searchable, so this check is only for main extensions.
|
||||
if !extension.searchable() {
|
||||
return Err(format!(
|
||||
"invalid extension {}, field [settings] is currently only allowed in searchable extension, this type of extension is not searchable [{}]",
|
||||
extension.id, extension.r#type
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -124,9 +141,10 @@ fn check_sub_extension_only(
|
||||
if sub_extension.commands.is_some()
|
||||
|| sub_extension.scripts.is_some()
|
||||
|| sub_extension.quicklinks.is_some()
|
||||
|| sub_extension.views.is_some()
|
||||
{
|
||||
return Err(format!(
|
||||
"invalid sub-extension [{}-{}]: fields [commands/scripts/quicklinks] should not be set in sub-extensions",
|
||||
"invalid sub-extension [{}-{}]: fields [commands/scripts/quicklinks/views] should not be set in sub-extensions",
|
||||
extension_id, sub_extension.id
|
||||
));
|
||||
}
|
||||
@@ -198,16 +216,47 @@ 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(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::extension::{CommandAction, Quicklink, QuicklinkLink, QuicklinkLinkComponent};
|
||||
use crate::extension::{
|
||||
CommandAction, ExtensionSettings, Quicklink, QuicklinkLink, QuicklinkLinkComponent,
|
||||
};
|
||||
|
||||
/// Helper function to create a basic valid extension
|
||||
fn create_basic_extension(id: &str, extension_type: ExtensionType) -> Extension {
|
||||
let page = if extension_type == ExtensionType::View {
|
||||
Some("index.html".into())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Extension {
|
||||
id: id.to_string(),
|
||||
name: "Test Extension".to_string(),
|
||||
@@ -221,9 +270,13 @@ mod tests {
|
||||
commands: None,
|
||||
scripts: None,
|
||||
quicklinks: None,
|
||||
views: None,
|
||||
alias: None,
|
||||
hotkey: None,
|
||||
enabled: true,
|
||||
page,
|
||||
ui: None,
|
||||
permission: None,
|
||||
settings: None,
|
||||
screenshots: None,
|
||||
url: None,
|
||||
@@ -309,6 +362,29 @@ mod tests {
|
||||
.contains("only extension of type [Group] and [Extension] can have sub-extensions")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_non_searchable_extension_set_field_settings() {
|
||||
let mut extension = create_basic_extension("test-group", ExtensionType::Group);
|
||||
extension.settings = Some(ExtensionSettings {
|
||||
hide_before_open: None,
|
||||
});
|
||||
let error_msg = general_check(&extension).unwrap_err();
|
||||
assert!(
|
||||
error_msg
|
||||
.contains("field [settings] is currently only allowed in searchable extension")
|
||||
);
|
||||
|
||||
let mut extension = create_basic_extension("test-extension", ExtensionType::Extension);
|
||||
extension.settings = Some(ExtensionSettings {
|
||||
hide_before_open: None,
|
||||
});
|
||||
let error_msg = general_check(&extension).unwrap_err();
|
||||
assert!(
|
||||
error_msg
|
||||
.contains("field [settings] is currently only allowed in searchable extension")
|
||||
);
|
||||
}
|
||||
/* test_check_main_extension_only */
|
||||
|
||||
/* test check_main_extension_or_sub_extension */
|
||||
@@ -366,6 +442,36 @@ mod tests {
|
||||
.contains("field [quicklink] is set for a non-Quicklink extension")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_view_must_have_page_field() {
|
||||
let mut extension = create_basic_extension("test-view", ExtensionType::View);
|
||||
// create_basic_extension() will set its page field if type is View, clear it
|
||||
extension.page = None;
|
||||
|
||||
let result = general_check(&extension);
|
||||
assert!(result.is_err());
|
||||
assert!(
|
||||
result
|
||||
.unwrap_err()
|
||||
.contains("field [page] should be set for a View extension")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_non_view_cannot_have_page_field() {
|
||||
let mut extension = create_basic_extension("test-cmd", ExtensionType::Command);
|
||||
extension.action = Some(create_command_action());
|
||||
extension.page = Some("index.html".into());
|
||||
|
||||
let result = general_check(&extension);
|
||||
assert!(result.is_err());
|
||||
assert!(
|
||||
result
|
||||
.unwrap_err()
|
||||
.contains("field [page] is set for a non-View extension")
|
||||
);
|
||||
}
|
||||
/* test check_main_extension_or_sub_extension */
|
||||
|
||||
/* Test check_sub_extension_only */
|
||||
@@ -431,11 +537,9 @@ mod tests {
|
||||
|
||||
let result = general_check(&extension);
|
||||
assert!(result.is_err());
|
||||
assert!(
|
||||
result.unwrap_err().contains(
|
||||
"fields [commands/scripts/quicklinks] should not be set in sub-extensions"
|
||||
)
|
||||
);
|
||||
assert!(result.unwrap_err().contains(
|
||||
"fields [commands/scripts/quicklinks/views] should not be set in sub-extensions"
|
||||
));
|
||||
}
|
||||
/* Test check_sub_extension_only */
|
||||
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
use crate::extension::PLUGIN_JSON_FILE_NAME;
|
||||
use crate::extension::third_party::check::general_check;
|
||||
use crate::extension::third_party::install::{
|
||||
filter_out_incompatible_sub_extensions, is_extension_installed,
|
||||
filter_out_incompatible_sub_extensions, is_extension_installed, view_extension_convert_pages,
|
||||
};
|
||||
use crate::extension::third_party::{
|
||||
THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE, get_third_party_extension_directory,
|
||||
};
|
||||
use crate::extension::{Extension, canonicalize_relative_icon_path};
|
||||
use crate::extension::{
|
||||
Extension, canonicalize_relative_icon_path, canonicalize_relative_page_path,
|
||||
};
|
||||
use crate::util::platform::Platform;
|
||||
use serde_json::Value as Json;
|
||||
use std::path::Path;
|
||||
@@ -165,6 +167,14 @@ pub(crate) async fn install_local_extension(
|
||||
// extensions that are not, filter them out.
|
||||
filter_out_incompatible_sub_extensions(&mut extension, current_platform);
|
||||
|
||||
// We are going to modify our third-party extension list, grab the write lock
|
||||
// to ensure exclusive access.
|
||||
let mut third_party_ext_list_write_lock = THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE
|
||||
.get()
|
||||
.expect("global third party search source not set")
|
||||
.write_lock()
|
||||
.await;
|
||||
|
||||
// Create destination directory
|
||||
let dest_dir = get_third_party_extension_directory(&tauri_app_handle)
|
||||
.join(DEVELOPER_ID_LOCAL)
|
||||
@@ -211,15 +221,20 @@ pub(crate) async fn install_local_extension(
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Canonicalize relative icon paths
|
||||
/*
|
||||
* Call convert_page() to update the page files. This has to be done after
|
||||
* writing the extension files because we will edit them.
|
||||
*
|
||||
* HTTP links will be skipped.
|
||||
*/
|
||||
view_extension_convert_pages(&extension, &dest_dir).await?;
|
||||
|
||||
// Canonicalize relative icon and page paths
|
||||
canonicalize_relative_icon_path(&dest_dir, &mut extension)?;
|
||||
canonicalize_relative_page_path(&dest_dir, &mut extension)?;
|
||||
|
||||
// Add extension to the search source
|
||||
THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE
|
||||
.get()
|
||||
.unwrap()
|
||||
.add_extension(extension)
|
||||
.await;
|
||||
third_party_ext_list_write_lock.push(extension);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
556
src-tauri/src/extension/third_party/install/mod.rs
vendored
556
src-tauri/src/extension/third_party/install/mod.rs
vendored
@@ -3,38 +3,56 @@
|
||||
//!
|
||||
//! # How
|
||||
//!
|
||||
//! Technically, installing an extension involves the following steps:
|
||||
//!
|
||||
//! 1. Correct the `plugin.json` JSON if it does not conform to our `struct Extension`
|
||||
//! definition.
|
||||
//! Technically, installing an extension involves the following steps. The order
|
||||
//! may vary between implementations.
|
||||
//!
|
||||
//! 2. Write the extension files to the corresponding location
|
||||
//! 1. Check if it is already installed, if so, return
|
||||
//!
|
||||
//! 2. Correct the `plugin.json` JSON if it does not conform to our `struct
|
||||
//! Extension` definition. This can happen because the JSON written by
|
||||
//! developers is in a simplified form for a better developer experience.
|
||||
//!
|
||||
//! 3. Validate the corrected `plugin.json`
|
||||
//! 1. misc checks
|
||||
//! 2. Platform compatibility check
|
||||
//!
|
||||
//! 4. Write the extension files to the corresponding location
|
||||
//!
|
||||
//! * developer directory
|
||||
//! * extension directory
|
||||
//! * assets directory
|
||||
//! * various assets files, e.g., "icon.png"
|
||||
//! * plugin.json file
|
||||
//! * View pages if exist
|
||||
//!
|
||||
//! 3. Canonicalize the `Extension.icon` fields if they are relative paths
|
||||
//! (relative to the `assets` directory)
|
||||
//! 5. If this extension contains any View extensions, call `convert_page()`
|
||||
//! on them to make them loadable by Tauri/webview.
|
||||
//!
|
||||
//! 4. Deserialize the `plugin.json` file to a `struct Extension`, and call
|
||||
//! `THIRD_PARTY_EXTENSIONS_DIRECTORY.add_extension(extension)` to add it to
|
||||
//! the in-memory extension list.
|
||||
//! See `convert_page()` for more info.
|
||||
//!
|
||||
//! 6. Canonicalize `Extension.icon` and `Extension.page` fields if they are
|
||||
//! relative paths
|
||||
//!
|
||||
//! * icon: relative to the `assets` directory
|
||||
//! * page: relative to the extension root directory
|
||||
//!
|
||||
//! 7. Add the extension to the in-memory extension list.
|
||||
|
||||
pub(crate) mod local_extension;
|
||||
pub(crate) mod store;
|
||||
|
||||
use crate::extension::Extension;
|
||||
use crate::extension::ExtensionType;
|
||||
use crate::util::platform::Platform;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE;
|
||||
|
||||
pub(crate) async fn is_extension_installed(developer: &str, extension_id: &str) -> bool {
|
||||
THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE
|
||||
.get()
|
||||
.unwrap()
|
||||
.expect("global third party search source not set")
|
||||
.extension_exists(developer, extension_id)
|
||||
.await
|
||||
}
|
||||
@@ -51,14 +69,16 @@ pub(crate) fn filter_out_incompatible_sub_extensions(
|
||||
return;
|
||||
}
|
||||
|
||||
// For main extensions, None means all.
|
||||
let main_extension_supported_platforms = extension.platforms.clone().unwrap_or(Platform::all());
|
||||
|
||||
// Filter commands
|
||||
if let Some(ref mut commands) = extension.commands {
|
||||
commands.retain(|sub_ext| {
|
||||
// If platforms is None, the sub-extension is compatible with all platforms
|
||||
if let Some(ref platforms) = sub_ext.platforms {
|
||||
platforms.contains(¤t_platform)
|
||||
} else {
|
||||
true
|
||||
main_extension_supported_platforms.contains(¤t_platform)
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -66,11 +86,10 @@ pub(crate) fn filter_out_incompatible_sub_extensions(
|
||||
// Filter scripts
|
||||
if let Some(ref mut scripts) = extension.scripts {
|
||||
scripts.retain(|sub_ext| {
|
||||
// If platforms is None, the sub-extension is compatible with all platforms
|
||||
if let Some(ref platforms) = sub_ext.platforms {
|
||||
platforms.contains(¤t_platform)
|
||||
} else {
|
||||
true
|
||||
main_extension_supported_platforms.contains(¤t_platform)
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -78,14 +97,194 @@ pub(crate) fn filter_out_incompatible_sub_extensions(
|
||||
// Filter quicklinks
|
||||
if let Some(ref mut quicklinks) = extension.quicklinks {
|
||||
quicklinks.retain(|sub_ext| {
|
||||
// If platforms is None, the sub-extension is compatible with all platforms
|
||||
if let Some(ref platforms) = sub_ext.platforms {
|
||||
platforms.contains(¤t_platform)
|
||||
} else {
|
||||
true
|
||||
main_extension_supported_platforms.contains(¤t_platform)
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Filter views
|
||||
if let Some(ref mut views) = extension.views {
|
||||
views.retain(|sub_ext| {
|
||||
if let Some(ref platforms) = sub_ext.platforms {
|
||||
platforms.contains(¤t_platform)
|
||||
} else {
|
||||
main_extension_supported_platforms.contains(¤t_platform)
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert the page file to make it loadable by the Tauri/Webview.
|
||||
pub(crate) async fn convert_page(absolute_page_path: &Path) -> Result<(), String> {
|
||||
assert!(absolute_page_path.is_absolute());
|
||||
|
||||
let page_content = tokio::fs::read_to_string(absolute_page_path)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let new_page_content = _convert_page(&page_content, absolute_page_path)?;
|
||||
|
||||
// overwrite it
|
||||
tokio::fs::write(absolute_page_path, new_page_content)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// NOTE: There is no Rust implementation of `convertFileSrc()` in Tauri. Our
|
||||
/// impl here is based on [comment](https://github.com/tauri-apps/tauri/issues/12022#issuecomment-2572879115)
|
||||
fn convert_file_src(path: &Path) -> Result<String, String> {
|
||||
#[cfg(any(windows, target_os = "android"))]
|
||||
let base = "http://asset.localhost/";
|
||||
#[cfg(not(any(windows, target_os = "android")))]
|
||||
let base = "asset://localhost/";
|
||||
|
||||
let path =
|
||||
dunce::canonicalize(path).map_err(|e| format!("Failed to canonicalize path: {}", e))?;
|
||||
let path_str = path.to_string_lossy();
|
||||
let encoded = urlencoding::encode(&path_str);
|
||||
|
||||
Ok(format!("{base}{encoded}"))
|
||||
}
|
||||
|
||||
/// Tauri cannot directly access the file system, to make a file loadable, we
|
||||
/// have to `canonicalize()` and `convertFileSrc()` its path before passing it
|
||||
/// to Tauri.
|
||||
///
|
||||
/// View extension's page is a HTML file that Coco (Tauri) will load, we need
|
||||
/// to process all `<PATH>` tags:
|
||||
///
|
||||
/// 1. `<script type="xxx" crossorigin src="<PATH>"></script>`
|
||||
/// 2. `<a href="<PATH>">xxx</a>`
|
||||
/// 3. `<link rel="xxx" href="<PATH>"/>`
|
||||
/// 4. `<img class="xxx" src="<PATH>" alt="xxx"/>`
|
||||
fn _convert_page(page_content: &str, absolute_page_path: &Path) -> Result<String, String> {
|
||||
use scraper::{Html, Selector};
|
||||
|
||||
/// Helper function.
|
||||
///
|
||||
/// Search `document` for the tag attributes specified by `tag_with_attribute`
|
||||
/// and `tag_attribute`, call `convert_file_src()`, then update the attribute
|
||||
/// value with the function return value.
|
||||
fn modify_tag_attributes(
|
||||
document: &Html,
|
||||
modified_html: &mut String,
|
||||
base_dir: &Path,
|
||||
tag_with_attribute: &str,
|
||||
tag_attribute: &str,
|
||||
) -> Result<(), String> {
|
||||
let script_selector = Selector::parse(tag_with_attribute).unwrap();
|
||||
for element in document.select(&script_selector) {
|
||||
if let Some(src) = element.value().attr(tag_attribute) {
|
||||
if !src.starts_with("http://")
|
||||
&& !src.starts_with("https://")
|
||||
&& !src.starts_with("asset://")
|
||||
&& !src.starts_with("http://asset.localhost/")
|
||||
{
|
||||
// It could be a path like "/assets/index-41be3ec9.js", but it
|
||||
// is still a relative path. We need to remove the starting /
|
||||
// or path.join() will think it is an absolute path and does nothing
|
||||
let corrected_src = if src.starts_with('/') { &src[1..] } else { src };
|
||||
|
||||
let full_path = base_dir.join(corrected_src);
|
||||
|
||||
let converted_path = convert_file_src(full_path.as_path())?;
|
||||
*modified_html = modified_html.replace(
|
||||
&format!("{}=\"{}\"", tag_attribute, src),
|
||||
&format!("{}=\"{}\"", tag_attribute, converted_path),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
let base_dir = absolute_page_path
|
||||
.parent()
|
||||
.ok_or_else(|| format!("page path is invalid, it should have a parent path"))?;
|
||||
let document: Html = Html::parse_document(page_content);
|
||||
let mut modified_html: String = page_content.to_string();
|
||||
|
||||
modify_tag_attributes(
|
||||
&document,
|
||||
&mut modified_html,
|
||||
base_dir,
|
||||
"script[src]",
|
||||
"src",
|
||||
)?;
|
||||
modify_tag_attributes(&document, &mut modified_html, base_dir, "a[href]", "href")?;
|
||||
modify_tag_attributes(
|
||||
&document,
|
||||
&mut modified_html,
|
||||
base_dir,
|
||||
"link[href]",
|
||||
"href",
|
||||
)?;
|
||||
modify_tag_attributes(&document, &mut modified_html, base_dir, "img[src]", "src")?;
|
||||
|
||||
Ok(modified_html)
|
||||
}
|
||||
|
||||
async fn view_extension_convert_pages(
|
||||
extension: &Extension,
|
||||
extension_directory: &Path,
|
||||
) -> Result<(), String> {
|
||||
let pages: Vec<&str> = {
|
||||
if extension.r#type == ExtensionType::View {
|
||||
let page = extension
|
||||
.page
|
||||
.as_ref()
|
||||
.expect("View extension should set its page field");
|
||||
|
||||
vec![page.as_str()]
|
||||
} else if extension.r#type.contains_sub_items()
|
||||
&& let Some(ref views) = extension.views
|
||||
{
|
||||
let mut pages = Vec::with_capacity(views.len());
|
||||
|
||||
for view in views.iter() {
|
||||
let page = view
|
||||
.page
|
||||
.as_ref()
|
||||
.expect("View extension should set its page field");
|
||||
|
||||
pages.push(page.as_str());
|
||||
}
|
||||
|
||||
pages
|
||||
} else {
|
||||
// No pages in this extension
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
fn canonicalize_page_path(page_path: &Path, extension_root: &Path) -> PathBuf {
|
||||
if page_path.is_relative() {
|
||||
// It is relative to the extension root directory
|
||||
extension_root.join(page_path)
|
||||
} else {
|
||||
page_path.into()
|
||||
}
|
||||
}
|
||||
for page in pages {
|
||||
/*
|
||||
* Skip HTTP links
|
||||
*/
|
||||
if let Ok(url) = url::Url::parse(page)
|
||||
&& ["http", "https"].contains(&url.scheme())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let path = canonicalize_page_path(Path::new(page), &extension_directory);
|
||||
convert_page(&path).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -113,10 +312,14 @@ mod tests {
|
||||
commands: None,
|
||||
scripts: None,
|
||||
quicklinks: None,
|
||||
views: None,
|
||||
alias: None,
|
||||
hotkey: None,
|
||||
enabled: true,
|
||||
settings: None,
|
||||
page: None,
|
||||
ui: None,
|
||||
permission: None,
|
||||
screenshots: None,
|
||||
url: None,
|
||||
version: None,
|
||||
@@ -154,10 +357,15 @@ mod tests {
|
||||
ExtensionType::Script,
|
||||
Some(HashSet::from([Platform::Macos])),
|
||||
)];
|
||||
let views = vec![create_test_extension(
|
||||
ExtensionType::View,
|
||||
Some(HashSet::from([Platform::Macos])),
|
||||
)];
|
||||
// Set sub extensions
|
||||
main_extension.commands = Some(commands);
|
||||
main_extension.quicklinks = Some(quicklinks);
|
||||
main_extension.scripts = Some(scripts);
|
||||
main_extension.views = Some(views);
|
||||
|
||||
// Current platform is Linux, all the sub extensions should be filtered out.
|
||||
filter_out_incompatible_sub_extensions(&mut main_extension, Platform::Linux);
|
||||
@@ -166,6 +374,7 @@ mod tests {
|
||||
assert!(main_extension.commands.unwrap().is_empty());
|
||||
assert!(main_extension.quicklinks.unwrap().is_empty());
|
||||
assert!(main_extension.scripts.unwrap().is_empty());
|
||||
assert!(main_extension.views.unwrap().is_empty());
|
||||
}
|
||||
|
||||
/// Sub extensions are compatible with all the platforms, nothing to filter out.
|
||||
@@ -186,10 +395,15 @@ mod tests {
|
||||
ExtensionType::Script,
|
||||
Some(Platform::all()),
|
||||
)];
|
||||
let views = vec![create_test_extension(
|
||||
ExtensionType::View,
|
||||
Some(Platform::all()),
|
||||
)];
|
||||
// Set sub extensions
|
||||
main_extension.commands = Some(commands);
|
||||
main_extension.quicklinks = Some(quicklinks);
|
||||
main_extension.scripts = Some(scripts);
|
||||
main_extension.views = Some(views);
|
||||
|
||||
// Current platform is Linux, all the sub extensions should be filtered out.
|
||||
filter_out_incompatible_sub_extensions(&mut main_extension, Platform::Linux);
|
||||
@@ -198,19 +412,23 @@ mod tests {
|
||||
assert_eq!(main_extension.commands.unwrap().len(), 1);
|
||||
assert_eq!(main_extension.quicklinks.unwrap().len(), 1);
|
||||
assert_eq!(main_extension.scripts.unwrap().len(), 1);
|
||||
assert_eq!(main_extension.views.unwrap().len(), 1);
|
||||
}
|
||||
|
||||
// `platforms: None` means all platforms as well
|
||||
// main extension is compatible with all platforms, sub extension's platforms
|
||||
// is None, which means all platforms are supported
|
||||
{
|
||||
let mut main_extension = create_test_extension(ExtensionType::Group, None);
|
||||
// init sub extensions, which are compatible with all the platforms
|
||||
let commands = vec![create_test_extension(ExtensionType::Command, None)];
|
||||
let quicklinks = vec![create_test_extension(ExtensionType::Quicklink, None)];
|
||||
let scripts = vec![create_test_extension(ExtensionType::Script, None)];
|
||||
let views = vec![create_test_extension(ExtensionType::View, None)];
|
||||
// Set sub extensions
|
||||
main_extension.commands = Some(commands);
|
||||
main_extension.quicklinks = Some(quicklinks);
|
||||
main_extension.scripts = Some(scripts);
|
||||
main_extension.views = Some(views);
|
||||
|
||||
// Current platform is Linux, all the sub extensions should be filtered out.
|
||||
filter_out_incompatible_sub_extensions(&mut main_extension, Platform::Linux);
|
||||
@@ -219,6 +437,308 @@ mod tests {
|
||||
assert_eq!(main_extension.commands.unwrap().len(), 1);
|
||||
assert_eq!(main_extension.quicklinks.unwrap().len(), 1);
|
||||
assert_eq!(main_extension.scripts.unwrap().len(), 1);
|
||||
assert_eq!(main_extension.views.unwrap().len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_main_extension_is_incompatible_sub_extension_platforms_none() {
|
||||
{
|
||||
let mut main_extension =
|
||||
create_test_extension(ExtensionType::Group, Some(HashSet::from([Platform::Macos])));
|
||||
let commands = vec![create_test_extension(ExtensionType::Command, None)];
|
||||
main_extension.commands = Some(commands);
|
||||
filter_out_incompatible_sub_extensions(&mut main_extension, Platform::Linux);
|
||||
assert_eq!(main_extension.commands.unwrap().len(), 0);
|
||||
}
|
||||
|
||||
{
|
||||
let mut main_extension =
|
||||
create_test_extension(ExtensionType::Group, Some(HashSet::from([Platform::Macos])));
|
||||
let scripts = vec![create_test_extension(ExtensionType::Script, None)];
|
||||
main_extension.scripts = Some(scripts);
|
||||
filter_out_incompatible_sub_extensions(&mut main_extension, Platform::Linux);
|
||||
assert_eq!(main_extension.scripts.unwrap().len(), 0);
|
||||
}
|
||||
|
||||
{
|
||||
let mut main_extension =
|
||||
create_test_extension(ExtensionType::Group, Some(HashSet::from([Platform::Macos])));
|
||||
let quicklinks = vec![create_test_extension(ExtensionType::Quicklink, None)];
|
||||
main_extension.quicklinks = Some(quicklinks);
|
||||
filter_out_incompatible_sub_extensions(&mut main_extension, Platform::Linux);
|
||||
assert_eq!(main_extension.quicklinks.unwrap().len(), 0);
|
||||
}
|
||||
{
|
||||
let mut main_extension =
|
||||
create_test_extension(ExtensionType::Group, Some(HashSet::from([Platform::Macos])));
|
||||
let views = vec![create_test_extension(ExtensionType::View, None)];
|
||||
main_extension.views = Some(views);
|
||||
filter_out_incompatible_sub_extensions(&mut main_extension, Platform::Linux);
|
||||
assert_eq!(main_extension.views.unwrap().len(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_main_extension_compatible_sub_extension_platforms_none() {
|
||||
let mut main_extension =
|
||||
create_test_extension(ExtensionType::Group, Some(HashSet::from([Platform::Macos])));
|
||||
let views = vec![create_test_extension(ExtensionType::View, None)];
|
||||
main_extension.views = Some(views);
|
||||
filter_out_incompatible_sub_extensions(&mut main_extension, Platform::Macos);
|
||||
assert_eq!(main_extension.views.unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_convert_page_script_tag() {
|
||||
use tempfile::TempDir;
|
||||
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let html_file = temp_dir.path().join("test.html");
|
||||
let js_file = temp_dir.path().join("main.js");
|
||||
|
||||
let html_content = r#"<html><body><script src="main.js"></script></body></html>"#;
|
||||
std::fs::write(&html_file, html_content).unwrap();
|
||||
std::fs::write(&js_file, "").unwrap();
|
||||
|
||||
let result = _convert_page(html_content, &html_file).unwrap();
|
||||
|
||||
let path = convert_file_src(&js_file).unwrap();
|
||||
let expected = format!(
|
||||
"<html><body><script src=\"{}\"></script></body></html>",
|
||||
path
|
||||
);
|
||||
|
||||
assert_eq!(result, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_convert_page_script_tag_with_a_root_char() {
|
||||
use tempfile::TempDir;
|
||||
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let html_file = temp_dir.path().join("test.html");
|
||||
let js_file = temp_dir.path().join("main.js");
|
||||
|
||||
let html_content = r#"<html><body><script src="/main.js"></script></body></html>"#;
|
||||
std::fs::write(&html_file, html_content).unwrap();
|
||||
std::fs::write(&js_file, "").unwrap();
|
||||
|
||||
let result = _convert_page(html_content, &html_file).unwrap();
|
||||
|
||||
let path = convert_file_src(&js_file).unwrap();
|
||||
let expected = format!(
|
||||
"<html><body><script src=\"{}\"></script></body></html>",
|
||||
path
|
||||
);
|
||||
|
||||
assert_eq!(result, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_convert_page_a_tag() {
|
||||
use tempfile::TempDir;
|
||||
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let html_file = temp_dir.path().join("test.html");
|
||||
let js_file = temp_dir.path().join("main.js");
|
||||
|
||||
let html_content = r#"<html><body><a href="main.js">foo</a></body></html>"#;
|
||||
std::fs::write(&html_file, html_content).unwrap();
|
||||
std::fs::write(&js_file, "").unwrap();
|
||||
|
||||
let result = _convert_page(html_content, &html_file).unwrap();
|
||||
|
||||
let path = convert_file_src(&js_file).unwrap();
|
||||
let expected = format!("<html><body><a href=\"{}\">foo</a></body></html>", path);
|
||||
|
||||
assert_eq!(result, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_convert_page_a_tag_with_a_root_char() {
|
||||
use tempfile::TempDir;
|
||||
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let html_file = temp_dir.path().join("test.html");
|
||||
let js_file = temp_dir.path().join("main.js");
|
||||
|
||||
let html_content = r#"<html><body><a href="/main.js">foo</a></body></html>"#;
|
||||
std::fs::write(&html_file, html_content).unwrap();
|
||||
std::fs::write(&js_file, "").unwrap();
|
||||
|
||||
let result = _convert_page(html_content, &html_file).unwrap();
|
||||
|
||||
let path = convert_file_src(&js_file).unwrap();
|
||||
let expected = format!("<html><body><a href=\"{}\">foo</a></body></html>", path);
|
||||
|
||||
assert_eq!(result, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_convert_page_link_href_tag() {
|
||||
use tempfile::TempDir;
|
||||
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let html_file = temp_dir.path().join("test.html");
|
||||
let css_file = temp_dir.path().join("main.css");
|
||||
|
||||
let html_content = r#"<html><body><link rel="stylesheet" href="main.css"/></body></html>"#;
|
||||
std::fs::write(&html_file, html_content).unwrap();
|
||||
std::fs::write(&css_file, "").unwrap();
|
||||
|
||||
let result = _convert_page(html_content, &html_file).unwrap();
|
||||
|
||||
let path = convert_file_src(&css_file).unwrap();
|
||||
let expected = format!(
|
||||
"<html><body><link rel=\"stylesheet\" href=\"{}\"/></body></html>",
|
||||
path
|
||||
);
|
||||
|
||||
assert_eq!(result, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_convert_page_link_href_tag_with_a_root_tag() {
|
||||
use tempfile::TempDir;
|
||||
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let html_file = temp_dir.path().join("test.html");
|
||||
let css_file = temp_dir.path().join("main.css");
|
||||
|
||||
let html_content = r#"<html><body><link rel="stylesheet" href="/main.css"/></body></html>"#;
|
||||
std::fs::write(&html_file, html_content).unwrap();
|
||||
std::fs::write(&css_file, "").unwrap();
|
||||
|
||||
let result = _convert_page(html_content, &html_file).unwrap();
|
||||
|
||||
let path = convert_file_src(&css_file).unwrap();
|
||||
let expected = format!(
|
||||
"<html><body><link rel=\"stylesheet\" href=\"{}\"/></body></html>",
|
||||
path
|
||||
);
|
||||
|
||||
assert_eq!(result, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_convert_page_img_src_tag() {
|
||||
use tempfile::TempDir;
|
||||
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let html_file = temp_dir.path().join("test.html");
|
||||
let png_file = temp_dir.path().join("main.png");
|
||||
|
||||
let html_content =
|
||||
r#"<html><body> <img class="fit-picture" src="main.png" alt="xxx" /></body></html>"#;
|
||||
std::fs::write(&html_file, html_content).unwrap();
|
||||
std::fs::write(&png_file, "").unwrap();
|
||||
|
||||
let result = _convert_page(html_content, &html_file).unwrap();
|
||||
|
||||
let path = convert_file_src(&png_file).unwrap();
|
||||
let expected = format!(
|
||||
"<html><body> <img class=\"fit-picture\" src=\"{}\" alt=\"xxx\" /></body></html>",
|
||||
path
|
||||
);
|
||||
|
||||
assert_eq!(result, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_convert_page_img_src_tag_with_a_root_tag() {
|
||||
use tempfile::TempDir;
|
||||
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let html_file = temp_dir.path().join("test.html");
|
||||
let png_file = temp_dir.path().join("main.png");
|
||||
|
||||
let html_content =
|
||||
r#"<html><body> <img class="fit-picture" src="/main.png" alt="xxx" /></body></html>"#;
|
||||
std::fs::write(&html_file, html_content).unwrap();
|
||||
std::fs::write(&png_file, "").unwrap();
|
||||
|
||||
let result = _convert_page(html_content, &html_file).unwrap();
|
||||
|
||||
let path = convert_file_src(&png_file).unwrap();
|
||||
let expected = format!(
|
||||
"<html><body> <img class=\"fit-picture\" src=\"{}\" alt=\"xxx\" /></body></html>",
|
||||
path
|
||||
);
|
||||
|
||||
assert_eq!(result, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_convert_page_contain_both_script_and_a_tags() {
|
||||
use tempfile::TempDir;
|
||||
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let html_file = temp_dir.path().join("test.html");
|
||||
let js_file = temp_dir.path().join("main.js");
|
||||
|
||||
let html_content =
|
||||
r#"<html><body><a href="main.js">foo</a><script src="main.js"></script></body></html>"#;
|
||||
std::fs::write(&html_file, html_content).unwrap();
|
||||
std::fs::write(&js_file, "").unwrap();
|
||||
|
||||
let result = _convert_page(html_content, &html_file).unwrap();
|
||||
|
||||
let path = convert_file_src(&js_file).unwrap();
|
||||
let expected = format!(
|
||||
"<html><body><a href=\"{}\">foo</a><script src=\"{}\"></script></body></html>",
|
||||
path, path
|
||||
);
|
||||
|
||||
assert_eq!(result, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_convert_page_contain_both_script_and_a_tags_with_root_char() {
|
||||
use tempfile::TempDir;
|
||||
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let html_file = temp_dir.path().join("test.html");
|
||||
let js_file = temp_dir.path().join("main.js");
|
||||
|
||||
let html_content = r#"<html><body><a href="/main.js">foo</a><script src="/main.js"></script></body></html>"#;
|
||||
std::fs::write(&html_file, html_content).unwrap();
|
||||
std::fs::write(&js_file, "").unwrap();
|
||||
|
||||
let result = _convert_page(html_content, &html_file).unwrap();
|
||||
|
||||
let path = convert_file_src(&js_file).unwrap();
|
||||
let expected = format!(
|
||||
"<html><body><a href=\"{}\">foo</a><script src=\"{}\"></script></body></html>",
|
||||
path, path
|
||||
);
|
||||
|
||||
assert_eq!(result, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_convert_page_empty_html() {
|
||||
use tempfile::TempDir;
|
||||
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let html_file = temp_dir.path().join("test.html");
|
||||
|
||||
let html_content = "";
|
||||
std::fs::write(&html_file, html_content).unwrap();
|
||||
let result = _convert_page(html_content, &html_file).unwrap();
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_convert_page_only_html_tag() {
|
||||
use tempfile::TempDir;
|
||||
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let html_file = temp_dir.path().join("test.html");
|
||||
|
||||
let html_content = "<html></html>";
|
||||
std::fs::write(&html_file, html_content).unwrap();
|
||||
let result = _convert_page(html_content, &html_file).unwrap();
|
||||
assert_eq!(result, html_content);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,9 +13,11 @@ use crate::extension::Extension;
|
||||
use crate::extension::PLUGIN_JSON_FILE_NAME;
|
||||
use crate::extension::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE;
|
||||
use crate::extension::canonicalize_relative_icon_path;
|
||||
use crate::extension::canonicalize_relative_page_path;
|
||||
use crate::extension::third_party::check::general_check;
|
||||
use crate::extension::third_party::get_third_party_extension_directory;
|
||||
use crate::extension::third_party::install::filter_out_incompatible_sub_extensions;
|
||||
use crate::extension::third_party::install::view_extension_convert_pages;
|
||||
use crate::server::http_client::HttpClient;
|
||||
use crate::util::platform::Platform;
|
||||
use async_trait::async_trait;
|
||||
@@ -172,6 +174,53 @@ pub(crate) async fn search_extension(
|
||||
Ok(extensions)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn extension_detail(
|
||||
id: String,
|
||||
) -> Result<Option<JsonObject<String, Json>>, String> {
|
||||
let path = format!("store/extension/{}", id);
|
||||
let response = HttpClient::get("default_coco_server", path.as_str(), None)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to send request: {:?}", e))?;
|
||||
|
||||
if response.status() == StatusCode::NOT_FOUND {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let response_dbg_str = format!("{:?}", response);
|
||||
// The response of an ES style GET request
|
||||
let mut response: JsonObject<String, Json> = response.json().await.unwrap_or_else(|_e| {
|
||||
panic!(
|
||||
"response body of [/store/extension/<ID>] is not a JSON object, response [{:?}]",
|
||||
response_dbg_str
|
||||
)
|
||||
});
|
||||
let source_json = response.remove("_source").unwrap_or_else(|| {
|
||||
panic!("field [_source] not found in the JSON returned from [/store/extension/<ID>]")
|
||||
});
|
||||
let mut source_obj = match source_json {
|
||||
Json::Object(obj) => obj,
|
||||
_ => panic!(
|
||||
"field [_source] should be a JSON object, but it is not, value: [{}]",
|
||||
source_json
|
||||
),
|
||||
};
|
||||
|
||||
let developer_id = match &source_obj["developer"]["id"] {
|
||||
Json::String(dev) => dev,
|
||||
_ => {
|
||||
panic!(
|
||||
"field [_source.developer.id] should be a string, but it is not, value: [{}]",
|
||||
source_obj["developer"]["id"]
|
||||
)
|
||||
}
|
||||
};
|
||||
let installed = is_extension_installed(developer_id, &id).await;
|
||||
source_obj.insert("installed".to_string(), Json::Bool(installed));
|
||||
|
||||
Ok(Some(source_obj))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn install_extension_from_store(
|
||||
tauri_app_handle: AppHandle,
|
||||
@@ -250,21 +299,40 @@ pub(crate) async fn install_extension_from_store(
|
||||
e
|
||||
);
|
||||
});
|
||||
let developer_id = extension.developer.clone().expect("developer has been set");
|
||||
|
||||
drop(plugin_json);
|
||||
|
||||
general_check(&extension)?;
|
||||
|
||||
let current_platform = Platform::current();
|
||||
if let Some(ref platforms) = extension.platforms {
|
||||
if !platforms.contains(¤t_platform) {
|
||||
return Err("this extension is not compatible with your OS".into());
|
||||
}
|
||||
}
|
||||
|
||||
if is_extension_installed(&developer_id, &id).await {
|
||||
return Err("Extension already installed.".into());
|
||||
}
|
||||
|
||||
// Extension is compatible with current platform, but it could contain sub
|
||||
// extensions that are not, filter them out.
|
||||
filter_out_incompatible_sub_extensions(&mut extension, Platform::current());
|
||||
filter_out_incompatible_sub_extensions(&mut extension, current_platform);
|
||||
|
||||
// We are going to modify our third-party extension list, grab the write lock
|
||||
// to ensure exclusive access.
|
||||
let mut third_party_ext_list_write_lock = THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE
|
||||
.get()
|
||||
.expect("global third party search source not set")
|
||||
.write_lock()
|
||||
.await;
|
||||
|
||||
// Write extension files to the extension directory
|
||||
let developer = extension.developer.clone().unwrap_or_default();
|
||||
let extension_id = extension.id.clone();
|
||||
let extension_directory = {
|
||||
let mut path = get_third_party_extension_directory(&tauri_app_handle);
|
||||
path.push(developer);
|
||||
path.push(developer_id);
|
||||
path.push(extension_id.as_str());
|
||||
path
|
||||
};
|
||||
@@ -328,14 +396,19 @@ pub(crate) async fn install_extension_from_store(
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Turn it into an absolute path if it is a valid relative path because frontend code need this.
|
||||
canonicalize_relative_icon_path(&extension_directory, &mut extension)?;
|
||||
/*
|
||||
* Call convert_page() to update the page files. This has to be done after
|
||||
* writing the extension files because we will edit them.
|
||||
*
|
||||
* HTTP links will be skipped.
|
||||
*/
|
||||
view_extension_convert_pages(&extension, &extension_directory).await?;
|
||||
|
||||
THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE
|
||||
.get()
|
||||
.unwrap()
|
||||
.add_extension(extension)
|
||||
.await;
|
||||
// Canonicalize relative icon and page paths
|
||||
canonicalize_relative_icon_path(&extension_directory, &mut extension)?;
|
||||
canonicalize_relative_page_path(&extension_directory, &mut extension)?;
|
||||
|
||||
third_party_ext_list_write_lock.push(extension);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
399
src-tauri/src/extension/third_party/mod.rs
vendored
399
src-tauri/src/extension/third_party/mod.rs
vendored
@@ -15,11 +15,16 @@ use crate::common::search::QuerySource;
|
||||
use crate::common::search::SearchQuery;
|
||||
use crate::common::traits::SearchSource;
|
||||
use crate::extension::ExtensionBundleIdBorrowed;
|
||||
use crate::extension::ExtensionType;
|
||||
use crate::extension::calculate_text_similarity;
|
||||
use crate::extension::canonicalize_relative_page_path;
|
||||
use crate::util::platform::Platform;
|
||||
use async_trait::async_trait;
|
||||
use borrowme::ToOwned;
|
||||
use check::general_check;
|
||||
use function_name::named;
|
||||
use std::collections::HashMap;
|
||||
use std::io::ErrorKind;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
@@ -42,7 +47,7 @@ pub(crate) fn get_third_party_extension_directory(tauri_app_handle: &AppHandle)
|
||||
app_data_dir
|
||||
}
|
||||
|
||||
pub(crate) async fn list_third_party_extensions(
|
||||
pub(crate) async fn load_third_party_extensions_from_directory(
|
||||
directory: &Path,
|
||||
) -> Result<Vec<Extension>, String> {
|
||||
let mut extensions_dir_iter = read_dir(&directory).await.map_err(|e| e.to_string())?;
|
||||
@@ -181,6 +186,7 @@ pub(crate) async fn list_third_party_extensions(
|
||||
|
||||
// Turn it into an absolute path if it is a valid relative path because frontend code needs this.
|
||||
canonicalize_relative_icon_path(&extension_dir.path(), &mut extension)?;
|
||||
canonicalize_relative_page_path(&extension_dir.path(), &mut extension)?;
|
||||
|
||||
extensions.push(extension);
|
||||
}
|
||||
@@ -202,7 +208,7 @@ pub(crate) async fn list_third_party_extensions(
|
||||
/// Since some `#[tauri::command]`s need to access it, we store it in a global
|
||||
/// static variable as well.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(super) struct ThirdPartyExtensionsSearchSource {
|
||||
pub(crate) struct ThirdPartyExtensionsSearchSource {
|
||||
inner: Arc<ThirdPartyExtensionsSearchSourceInner>,
|
||||
}
|
||||
|
||||
@@ -288,6 +294,11 @@ impl ThirdPartyExtensionsSearchSource {
|
||||
Self::_enable_extension(&tauri_app_handle, quicklink).await?;
|
||||
}
|
||||
}
|
||||
if let Some(views) = &extension.views {
|
||||
for view in views.iter().filter(|ext| ext.enabled) {
|
||||
Self::_enable_extension(&tauri_app_handle, view).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -330,6 +341,11 @@ impl ThirdPartyExtensionsSearchSource {
|
||||
Self::_disable_extension(tauri_app_handle, quicklink).await?;
|
||||
}
|
||||
}
|
||||
if let Some(views) = &extension.views {
|
||||
for view in views.iter().filter(|ext| ext.enabled) {
|
||||
Self::_disable_extension(tauri_app_handle, view).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -343,6 +359,11 @@ impl ThirdPartyExtensionsSearchSource {
|
||||
}
|
||||
}
|
||||
|
||||
/// Acquire the write lock to the extension list.
|
||||
pub(crate) async fn write_lock(&self) -> RwLockWriteGuard<'_, Vec<Extension>> {
|
||||
self.inner.extensions.write().await
|
||||
}
|
||||
|
||||
#[named]
|
||||
pub(super) async fn enable_extension(
|
||||
&self,
|
||||
@@ -457,7 +478,7 @@ impl ThirdPartyExtensionsSearchSource {
|
||||
|
||||
/// Initialize the third-party extensions, which literally means
|
||||
/// enabling/activating the enabled extensions.
|
||||
pub(super) async fn init(&self, tauri_app_handle: &AppHandle) -> Result<(), String> {
|
||||
pub(crate) async fn init(&self, tauri_app_handle: &AppHandle) -> Result<(), String> {
|
||||
let extensions_read_lock = self.inner.extensions.read().await;
|
||||
|
||||
for extension in extensions_read_lock.iter().filter(|ext| ext.enabled) {
|
||||
@@ -504,6 +525,24 @@ impl ThirdPartyExtensionsSearchSource {
|
||||
let on_opened = extension.on_opened().unwrap_or_else(|| panic!(
|
||||
"setting hotkey for an extension that cannot be opened, extension ID [{:?}], extension type [{:?}]", bundle_id, extension.r#type,
|
||||
));
|
||||
let url = on_opened.url();
|
||||
let extension_type_string = extension.r#type.to_string();
|
||||
let document = Document {
|
||||
id: extension.id.clone(),
|
||||
title: Some(extension.name.clone()),
|
||||
icon: Some(extension.icon.clone()),
|
||||
on_opened: Some(on_opened.clone()),
|
||||
url: Some(url),
|
||||
category: Some(extension_type_string.clone()),
|
||||
source: Some(DataSourceReference {
|
||||
id: Some(extension_type_string.clone()),
|
||||
name: Some(extension_type_string.clone()),
|
||||
icon: None,
|
||||
r#type: Some(extension_type_string),
|
||||
}),
|
||||
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let bundle_id_owned = bundle_id.to_owned();
|
||||
tauri_app_handle
|
||||
@@ -513,9 +552,16 @@ impl ThirdPartyExtensionsSearchSource {
|
||||
let bundle_id_clone = bundle_id_owned.clone();
|
||||
let app_handle_clone = tauri_app_handle.clone();
|
||||
|
||||
let document_clone = document.clone();
|
||||
if event.state() == ShortcutState::Pressed {
|
||||
async_runtime::spawn(async move {
|
||||
let result = open(app_handle_clone, on_opened_clone, None).await;
|
||||
let mut args = HashMap::new();
|
||||
args.insert(
|
||||
String::from("document"),
|
||||
serde_json::to_value(&document_clone).unwrap(),
|
||||
);
|
||||
|
||||
let result = open(app_handle_clone, on_opened_clone, Some(args)).await;
|
||||
if let Err(msg) = result {
|
||||
log::warn!(
|
||||
"failed to open extension [{:?}], error [{}]",
|
||||
@@ -628,48 +674,68 @@ impl ThirdPartyExtensionsSearchSource {
|
||||
.any(|ext| ext.developer.as_deref() == Some(developer) && ext.id == extension_id)
|
||||
}
|
||||
|
||||
/// Add `extension` to the **in-memory** extension list.
|
||||
pub(crate) async fn add_extension(&self, extension: Extension) {
|
||||
assert!(
|
||||
extension.developer.is_some(),
|
||||
"loaded third party extension should have its developer set"
|
||||
);
|
||||
pub(crate) async fn uninstall_extension(
|
||||
&self,
|
||||
tauri_app_handle: &AppHandle,
|
||||
developer: &str,
|
||||
extension_id: &str,
|
||||
) -> Result<(), String> {
|
||||
let mut write_lock = self.inner.extensions.write().await;
|
||||
|
||||
let mut write_lock_guard = self.inner.extensions.write().await;
|
||||
if write_lock_guard
|
||||
.iter()
|
||||
.any(|ext| ext.developer == extension.developer && ext.id == extension.id)
|
||||
{
|
||||
panic!(
|
||||
"extension [{}/{}] already installed",
|
||||
extension
|
||||
.developer
|
||||
.as_ref()
|
||||
.expect("just checked it is Some"),
|
||||
extension.id
|
||||
);
|
||||
}
|
||||
write_lock_guard.push(extension);
|
||||
}
|
||||
|
||||
/// Remove `extension` from the **in-memory** extension list.
|
||||
pub(crate) async fn remove_extension(&self, developer: &str, extension_id: &str) -> Extension {
|
||||
let mut write_lock_guard = self.inner.extensions.write().await;
|
||||
let Some(index) = write_lock_guard
|
||||
let Some(index) = write_lock
|
||||
.iter()
|
||||
.position(|ext| ext.developer.as_deref() == Some(developer) && ext.id == extension_id)
|
||||
else {
|
||||
panic!(
|
||||
"extension [{}/{}] not installed, but we are trying to remove it",
|
||||
return Err(format!(
|
||||
"The extension we are trying to uninstall [{}/{}] does not exist",
|
||||
developer, extension_id
|
||||
);
|
||||
));
|
||||
};
|
||||
let deleted_extension = write_lock.remove(index);
|
||||
|
||||
let extension_dir = {
|
||||
let mut path = get_third_party_extension_directory(&tauri_app_handle);
|
||||
path.push(developer);
|
||||
path.push(extension_id);
|
||||
|
||||
path
|
||||
};
|
||||
|
||||
write_lock_guard.remove(index)
|
||||
if let Err(e) = tokio::fs::remove_dir_all(extension_dir.as_path()).await {
|
||||
let error_kind = e.kind();
|
||||
if error_kind == ErrorKind::NotFound {
|
||||
// We accept this error because we do want it to not exist. But
|
||||
// since it is not a state we expect, throw a warning.
|
||||
log::warn!(
|
||||
"trying to uninstalling extension [developer {} id {}], but its directory does not exist",
|
||||
developer,
|
||||
extension_id
|
||||
);
|
||||
} else {
|
||||
return Err(format!(
|
||||
"failed to uninstall extension [developer {} id {}] due to error {}",
|
||||
developer, extension_id, e
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Unregister the extension hotkey, if set.
|
||||
//
|
||||
// Unregistering hotkey is the only thing that we will do when we disable
|
||||
// an extension, so we directly use this function here even though "disabling"
|
||||
// the extension that one is trying to uninstall does not make too much sense.
|
||||
Self::_disable_extension(&tauri_app_handle, &deleted_extension).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Take a point-in-time snapshot at the extension list and return it.
|
||||
pub(crate) async fn extensions_snapshot(&self) -> Vec<Extension> {
|
||||
self.inner.extensions.read().await.clone()
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) static THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE: OnceLock<ThirdPartyExtensionsSearchSource> =
|
||||
pub(crate) static THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE: OnceLock<ThirdPartyExtensionsSearchSource> =
|
||||
OnceLock::new();
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -718,11 +784,22 @@ impl SearchSource for ThirdPartyExtensionsSearchSource {
|
||||
|
||||
for extension in extensions_read_lock.iter().filter(|ext| ext.enabled) {
|
||||
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 {
|
||||
for command in commands.iter().filter(|cmd| cmd.enabled) {
|
||||
if let Some(hit) =
|
||||
extension_to_hit(command, &query_lower, opt_data_source.as_deref())
|
||||
{
|
||||
if let Some(hit) = extension_to_hit(
|
||||
command,
|
||||
&query_lower,
|
||||
opt_data_source.as_deref(),
|
||||
opt_main_extension_lowercase_name.as_deref(),
|
||||
) {
|
||||
hits.push(hit);
|
||||
}
|
||||
}
|
||||
@@ -730,9 +807,12 @@ impl SearchSource for ThirdPartyExtensionsSearchSource {
|
||||
|
||||
if let Some(ref scripts) = extension.scripts {
|
||||
for script in scripts.iter().filter(|script| script.enabled) {
|
||||
if let Some(hit) =
|
||||
extension_to_hit(script, &query_lower, opt_data_source.as_deref())
|
||||
{
|
||||
if let Some(hit) = extension_to_hit(
|
||||
script,
|
||||
&query_lower,
|
||||
opt_data_source.as_deref(),
|
||||
opt_main_extension_lowercase_name.as_deref(),
|
||||
) {
|
||||
hits.push(hit);
|
||||
}
|
||||
}
|
||||
@@ -744,6 +824,20 @@ impl SearchSource for ThirdPartyExtensionsSearchSource {
|
||||
quicklink,
|
||||
&query_lower,
|
||||
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(|link| link.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);
|
||||
}
|
||||
@@ -751,7 +845,7 @@ impl SearchSource for ThirdPartyExtensionsSearchSource {
|
||||
}
|
||||
} else {
|
||||
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);
|
||||
}
|
||||
@@ -777,10 +871,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,
|
||||
query_lower: &str,
|
||||
opt_data_source: Option<&str>,
|
||||
opt_main_extension_lowercase_name: Option<&str>,
|
||||
) -> Option<(Document, f64)> {
|
||||
if !extension.searchable() {
|
||||
return None;
|
||||
@@ -803,14 +918,26 @@ fn extension_to_hit(
|
||||
if let Some(title_score) =
|
||||
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
|
||||
// Alias is considered less important than title, so it gets a lower weight.
|
||||
if let Some(alias) = &extension.alias {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -846,183 +973,3 @@ fn extension_to_hit(
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
// Calculates a similarity score between a query and a text, aiming for a [0, 1] range.
|
||||
// Assumes query and text are already lowercased.
|
||||
fn calculate_text_similarity(query: &str, text: &str) -> Option<f64> {
|
||||
if query.is_empty() || text.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if text == query {
|
||||
return Some(1.0); // Perfect match
|
||||
}
|
||||
|
||||
let query_len = query.len() as f64;
|
||||
let text_len = text.len() as f64;
|
||||
let ratio = query_len / text_len;
|
||||
let mut score: f64 = 0.0;
|
||||
|
||||
// Case 1: Text starts with the query (prefix match)
|
||||
// Score: base 0.5, bonus up to 0.4 for how much of `text` is covered by `query`. Max 0.9.
|
||||
if text.starts_with(query) {
|
||||
score = score.max(0.5 + 0.4 * ratio);
|
||||
}
|
||||
|
||||
// Case 2: Text contains the query (substring match, not necessarily prefix)
|
||||
// Score: base 0.3, bonus up to 0.3. Max 0.6.
|
||||
// `score.max` ensures that if it's both a prefix and contains, the higher score (prefix) is taken.
|
||||
if text.contains(query) {
|
||||
score = score.max(0.3 + 0.3 * ratio);
|
||||
}
|
||||
|
||||
// Case 3: Fallback for "all query characters exist in text" (order-independent)
|
||||
if score < 0.2 {
|
||||
if query.chars().all(|c_q| text.contains(c_q)) {
|
||||
score = score.max(0.15); // Fixed low score for this weaker match type
|
||||
}
|
||||
}
|
||||
|
||||
if score > 0.0 {
|
||||
// Cap non-perfect matches slightly below 1.0 to make perfect (1.0) distinct.
|
||||
Some(score.min(0.95))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn uninstall_extension(
|
||||
tauri_app_handle: AppHandle,
|
||||
developer: String,
|
||||
extension_id: String,
|
||||
) -> Result<(), String> {
|
||||
let extension_dir = {
|
||||
let mut path = get_third_party_extension_directory(&tauri_app_handle);
|
||||
path.push(developer.as_str());
|
||||
path.push(extension_id.as_str());
|
||||
|
||||
path
|
||||
};
|
||||
if !extension_dir.try_exists().map_err(|e| e.to_string())? {
|
||||
panic!(
|
||||
"we are uninstalling extension [{}/{}], but there is no such extension files on disk",
|
||||
developer, extension_id
|
||||
)
|
||||
}
|
||||
tokio::fs::remove_dir_all(extension_dir.as_path())
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let extension = THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE
|
||||
.get()
|
||||
.unwrap()
|
||||
.remove_extension(&developer, &extension_id)
|
||||
.await;
|
||||
|
||||
// Unregister the extension hotkey, if set.
|
||||
//
|
||||
// Unregistering hotkey is the only thing that we will do when we disable
|
||||
// an extension, so we directly use this function here even though "disabling"
|
||||
// the extension that one is trying to uninstall does not make too much sense.
|
||||
ThirdPartyExtensionsSearchSource::_disable_extension(&tauri_app_handle, &extension).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// Helper function for approximate floating point comparison
|
||||
fn approx_eq(a: f64, b: f64) -> bool {
|
||||
(a - b).abs() < 1e-10
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_strings() {
|
||||
assert_eq!(calculate_text_similarity("", "text"), None);
|
||||
assert_eq!(calculate_text_similarity("query", ""), None);
|
||||
assert_eq!(calculate_text_similarity("", ""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_perfect_match() {
|
||||
assert_eq!(calculate_text_similarity("text", "text"), Some(1.0));
|
||||
assert_eq!(calculate_text_similarity("a", "a"), Some(1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_prefix_match() {
|
||||
// For "te" and "text":
|
||||
// score = 0.5 + 0.4 * (2/4) = 0.5 + 0.2 = 0.7
|
||||
let score = calculate_text_similarity("te", "text").unwrap();
|
||||
assert!(approx_eq(score, 0.7));
|
||||
|
||||
// For "tex" and "text":
|
||||
// score = 0.5 + 0.4 * (3/4) = 0.5 + 0.3 = 0.8
|
||||
let score = calculate_text_similarity("tex", "text").unwrap();
|
||||
assert!(approx_eq(score, 0.8));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_substring_match() {
|
||||
// For "ex" and "text":
|
||||
// score = 0.3 + 0.3 * (2/4) = 0.3 + 0.15 = 0.45
|
||||
let score = calculate_text_similarity("ex", "text").unwrap();
|
||||
assert!(approx_eq(score, 0.45));
|
||||
|
||||
// Prefix should score higher than substring
|
||||
assert!(
|
||||
calculate_text_similarity("te", "text").unwrap()
|
||||
> calculate_text_similarity("ex", "text").unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_character_presence() {
|
||||
// Characters present but not in sequence
|
||||
// "tac" in "contact" - not a substring, but all chars exist
|
||||
let score = calculate_text_similarity("tac", "contact").unwrap();
|
||||
assert!(approx_eq(0.3 + 0.3 * (3.0 / 7.0), score));
|
||||
|
||||
assert!(calculate_text_similarity("ac", "contact").is_some());
|
||||
|
||||
// Should not apply if some characters are missing
|
||||
assert_eq!(calculate_text_similarity("xyz", "contact"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_combined_scenarios() {
|
||||
// Test that character presence fallback doesn't override higher scores
|
||||
// "tex" is a prefix of "text" with score 0.8
|
||||
let score = calculate_text_similarity("tex", "text").unwrap();
|
||||
assert!(approx_eq(score, 0.8));
|
||||
|
||||
// Test a case where the characters exist but it's already a substring
|
||||
// "act" is a substring of "contact" with score > 0.2, so fallback won't apply
|
||||
let expected_score = 0.3 + 0.3 * (3.0 / 7.0);
|
||||
let actual_score = calculate_text_similarity("act", "contact").unwrap();
|
||||
assert!(approx_eq(actual_score, expected_score));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_similarity() {
|
||||
assert_eq!(calculate_text_similarity("xyz", "test"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_score_capping() {
|
||||
// Use a long query that's a prefix of a slightly longer text
|
||||
let long_text = "abcdefghijklmnopqrstuvwxyz";
|
||||
let long_prefix = "abcdefghijklmnopqrstuvwxy"; // All but last letter
|
||||
|
||||
// Expected score would be 0.5 + 0.4 * (25/26) = 0.5 + 0.385 = 0.885
|
||||
let expected_score = 0.5 + 0.4 * (25.0 / 26.0);
|
||||
let actual_score = calculate_text_similarity(long_prefix, long_text).unwrap();
|
||||
assert!(approx_eq(actual_score, expected_score));
|
||||
|
||||
// Verify that non-perfect matches are capped at 0.95
|
||||
assert!(calculate_text_similarity("almost", "almost perfect").unwrap() <= 0.95);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,18 +7,18 @@ mod server;
|
||||
mod settings;
|
||||
mod setup;
|
||||
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::traits::SearchSource;
|
||||
use crate::common::{CHECK_WINDOW_LABEL, MAIN_WINDOW_LABEL, SETTINGS_WINDOW_LABEL};
|
||||
use crate::server::servers::{load_or_insert_default_server, load_servers_token};
|
||||
use autostart::{change_autostart, ensure_autostart_state_consistent};
|
||||
use crate::util::logging::set_up_tauri_logger;
|
||||
use crate::util::prevent_default;
|
||||
use autostart::change_autostart;
|
||||
use lazy_static::lazy_static;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::OnceLock;
|
||||
use tauri::async_runtime::block_on;
|
||||
use tauri::plugin::TauriPlugin;
|
||||
use tauri::{AppHandle, Emitter, Manager, PhysicalPosition, WebviewWindow, WindowEvent};
|
||||
use tauri_plugin_autostart::MacosLauncher;
|
||||
|
||||
@@ -70,10 +70,8 @@ pub fn run() {
|
||||
|
||||
#[cfg(desktop)]
|
||||
{
|
||||
app_builder = app_builder.plugin(tauri_plugin_single_instance::init(|_app, argv, _cwd| {
|
||||
log::debug!("a new app instance was opened with {argv:?} and the deep link event was already triggered");
|
||||
// when defining deep link schemes at runtime, you must also check `argv` here
|
||||
}));
|
||||
app_builder =
|
||||
app_builder.plugin(tauri_plugin_single_instance::init(|_app, _argv, _cwd| {}));
|
||||
}
|
||||
|
||||
app_builder = app_builder
|
||||
@@ -96,7 +94,8 @@ pub fn run() {
|
||||
.build(),
|
||||
)
|
||||
.plugin(tauri_plugin_windows_version::init())
|
||||
.plugin(tauri_plugin_opener::init());
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(prevent_default::init());
|
||||
|
||||
// Conditional compilation for macOS
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -164,30 +163,26 @@ pub fn run() {
|
||||
extension::unregister_extension_hotkey,
|
||||
extension::is_extension_enabled,
|
||||
extension::third_party::install::store::search_extension,
|
||||
extension::third_party::install::store::extension_detail,
|
||||
extension::third_party::install::store::install_extension_from_store,
|
||||
extension::third_party::install::local_extension::install_local_extension,
|
||||
extension::third_party::uninstall_extension,
|
||||
extension::api::apis,
|
||||
extension::api::fs::read_dir,
|
||||
settings::set_allow_self_signature,
|
||||
settings::get_allow_self_signature,
|
||||
assistant::ask_ai,
|
||||
crate::common::document::open,
|
||||
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
||||
extension::built_in::file_search::config::get_file_system_config,
|
||||
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
||||
extension::built_in::file_search::config::set_file_system_config,
|
||||
server::synthesize::synthesize,
|
||||
util::file::get_file_icon,
|
||||
setup::backend_setup,
|
||||
util::app_lang::update_app_lang,
|
||||
#[cfg(target_os = "macos")]
|
||||
setup::toggle_move_to_active_space_attribute,
|
||||
util::path::path_absolute,
|
||||
util::logging::app_log_dir
|
||||
])
|
||||
.setup(|app| {
|
||||
let app_handle = app.handle().clone();
|
||||
GLOBAL_TAURI_APP_HANDLE
|
||||
.set(app_handle.clone())
|
||||
.expect("global tauri AppHandle already initialized");
|
||||
log::trace!("global Tauri AppHandle set");
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
log::trace!("hiding Dock icon on macOS");
|
||||
@@ -195,67 +190,21 @@ pub fn run() {
|
||||
log::trace!("Dock icon should be hidden now");
|
||||
}
|
||||
|
||||
let registry = SearchSourceRegistry::default();
|
||||
|
||||
app.manage(registry); // Store registry in Tauri's app state
|
||||
|
||||
// This has to be called before initializing extensions as doing that
|
||||
// requires access to the shortcut store, which will be set by this
|
||||
// function.
|
||||
shortcut::enable_shortcut(app);
|
||||
|
||||
block_on(async {
|
||||
init(app.handle()).await;
|
||||
|
||||
// We want all the extensions here, so no filter condition specified.
|
||||
match extension::list_extensions(app_handle.clone(), None, None, false).await {
|
||||
Ok(extensions) => {
|
||||
// Initializing extension relies on SearchSourceRegistry, so this should
|
||||
// be executed after `app.manage(registry)`
|
||||
if let Err(e) =
|
||||
extension::init_extensions(app_handle.clone(), extensions).await
|
||||
{
|
||||
log::error!("initializing extensions failed with error [{}]", e);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("listing extensions failed with error [{}]", e);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ensure_autostart_state_consistent(app)?;
|
||||
|
||||
// app.listen("theme-changed", move |event| {
|
||||
// if let Ok(payload) = serde_json::from_str::<ThemeChangedPayload>(event.payload()) {
|
||||
// // switch_tray_icon(app.app_handle(), payload.is_dark_mode);
|
||||
// log::debug!("Theme changed: is_dark_mode = {}", payload.is_dark_mode);
|
||||
// }
|
||||
// });
|
||||
|
||||
#[cfg(desktop)]
|
||||
{
|
||||
#[cfg(any(windows, target_os = "linux"))]
|
||||
{
|
||||
app.deep_link().register("coco")?;
|
||||
use tauri_plugin_deep_link::DeepLinkExt;
|
||||
app.deep_link().register_all()?;
|
||||
}
|
||||
}
|
||||
|
||||
// app.deep_link().on_open_url(|event| {
|
||||
// dbg!(event.urls());
|
||||
// });
|
||||
|
||||
let main_window = app.get_webview_window(MAIN_WINDOW_LABEL).unwrap();
|
||||
let settings_window = app.get_webview_window(SETTINGS_WINDOW_LABEL).unwrap();
|
||||
let check_window = app.get_webview_window(CHECK_WINDOW_LABEL).unwrap();
|
||||
/* ----------- This code must be executed on the main thread and must not be relocated. ----------- */
|
||||
let app_handle = app.app_handle();
|
||||
let main_window = app_handle.get_webview_window(MAIN_WINDOW_LABEL).unwrap();
|
||||
let settings_window = app_handle
|
||||
.get_webview_window(SETTINGS_WINDOW_LABEL)
|
||||
.unwrap();
|
||||
let check_window = app_handle.get_webview_window(CHECK_WINDOW_LABEL).unwrap();
|
||||
setup::default(
|
||||
app,
|
||||
app_handle,
|
||||
main_window.clone(),
|
||||
settings_window.clone(),
|
||||
check_window.clone(),
|
||||
);
|
||||
/* ----------- This code must be executed on the main thread and must not be relocated. ----------- */
|
||||
|
||||
Ok(())
|
||||
})
|
||||
@@ -318,25 +267,57 @@ async fn show_coco(app_handle: AppHandle) {
|
||||
if let Some(window) = app_handle.get_webview_window(MAIN_WINDOW_LABEL) {
|
||||
move_window_to_active_monitor(&window);
|
||||
|
||||
let _ = window.show();
|
||||
let _ = window.unminimize();
|
||||
let _ = window.set_focus();
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(target_os = "macos")] {
|
||||
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", ());
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn hide_coco(app: AppHandle) {
|
||||
if let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) {
|
||||
if let Err(err) = window.hide() {
|
||||
log::error!("Failed to hide the window: {}", err);
|
||||
async fn hide_coco(app_handle: AppHandle) {
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(target_os = "macos")] {
|
||||
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 {
|
||||
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) {
|
||||
@@ -473,135 +454,3 @@ async fn hide_check(app_handle: AppHandle) {
|
||||
|
||||
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!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
use coco_lib::util::logging::app_log_dir;
|
||||
use std::fs::OpenOptions;
|
||||
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
|
||||
fn setup_panic_hook() {
|
||||
|
||||
@@ -10,13 +10,10 @@ use function_name::named;
|
||||
use futures::StreamExt;
|
||||
use futures::stream::FuturesUnordered;
|
||||
use reqwest::StatusCode;
|
||||
use std::cmp::Reverse;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
use tauri::{AppHandle, Manager};
|
||||
use tokio::time::{Duration, timeout};
|
||||
|
||||
#[named]
|
||||
#[tauri::command]
|
||||
pub async fn query_coco_fusion(
|
||||
@@ -187,7 +184,6 @@ async fn query_coco_fusion_multi_query_sources(
|
||||
|
||||
let mut futures = FuturesUnordered::new();
|
||||
|
||||
let query_source_list_len = query_source_trait_object_list.len();
|
||||
for query_source_trait_object in query_source_trait_object_list {
|
||||
let query_source = query_source_trait_object.get_type().clone();
|
||||
let tauri_app_handle_clone = tauri_app_handle.clone();
|
||||
@@ -208,14 +204,8 @@ async fn query_coco_fusion_multi_query_sources(
|
||||
}
|
||||
|
||||
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 all_hits: Vec<(String, QueryHits, f64)> = Vec::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
|
||||
}
|
||||
let mut all_hits_grouped_by_source_id: HashMap<String, Vec<QueryHits>> = HashMap::new();
|
||||
|
||||
while let Some((query_source, timeout_result)) = futures.next().await {
|
||||
match timeout_result {
|
||||
@@ -246,12 +236,10 @@ async fn query_coco_fusion_multi_query_sources(
|
||||
document,
|
||||
};
|
||||
|
||||
all_hits.push((source_id.clone(), query_hit.clone(), score));
|
||||
|
||||
hits_per_source
|
||||
all_hits_grouped_by_source_id
|
||||
.entry(source_id.clone())
|
||||
.or_insert_with(Vec::new)
|
||||
.push((query_hit, score));
|
||||
.push(query_hit);
|
||||
}
|
||||
}
|
||||
Err(search_error) => {
|
||||
@@ -267,109 +255,117 @@ async fn query_coco_fusion_multi_query_sources(
|
||||
}
|
||||
}
|
||||
|
||||
// Sort hits within each source by score (descending)
|
||||
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));
|
||||
let n_sources = all_hits_grouped_by_source_id.len();
|
||||
|
||||
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 {
|
||||
size as usize / total_sources
|
||||
} else {
|
||||
size as usize
|
||||
};
|
||||
/*
|
||||
* Sort hits within each source by score (descending) in case data sources
|
||||
* do not sort them
|
||||
*/
|
||||
for hits in all_hits_grouped_by_source_id.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();
|
||||
|
||||
// max_hits_per_source could be 0, then `final_hits_grouped_by_source_id`
|
||||
// would be empty. But we don't need to worry about this case as we will
|
||||
// populate hits later.
|
||||
let max_hits_per_source = size as usize / n_sources;
|
||||
for (source_id, hits) in all_hits_grouped_by_source_id.iter() {
|
||||
let hits_taken = if hits.len() > max_hits_per_source {
|
||||
pruned.insert(&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(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 seen_docs = HashSet::new(); // To track documents we've already added
|
||||
|
||||
// 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);
|
||||
for (_source_id, hits) in final_hits_grouped_by_source_id {
|
||||
final_hits.extend(hits);
|
||||
}
|
||||
|
||||
// **Sort final hits by score descending**
|
||||
@@ -379,6 +375,11 @@ async fn query_coco_fusion_multi_query_sources(
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
});
|
||||
|
||||
// Truncate `final_hits` in case it contains more than `size` hits
|
||||
//
|
||||
// Technically, we are safe to not do this. But since it is trivial, double-check it.
|
||||
final_hits.truncate(size as usize);
|
||||
|
||||
if final_hits.len() < 5 {
|
||||
//TODO: Add a recommendation system to suggest more sources
|
||||
log::info!(
|
||||
@@ -395,30 +396,85 @@ async fn query_coco_fusion_multi_query_sources(
|
||||
})
|
||||
}
|
||||
|
||||
fn boosted_levenshtein_rerank(query: &str, titles: Vec<(usize, &str)>) -> Vec<(usize, f64)> {
|
||||
use strsim::levenshtein;
|
||||
use std::collections::HashSet;
|
||||
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();
|
||||
|
||||
titles
|
||||
.into_iter()
|
||||
.map(|(idx, title)| {
|
||||
let mut score = 0.0;
|
||||
for (source_id, hits) in all_hits_grouped_by_source_id.iter_mut() {
|
||||
// Skip special sources like calculator
|
||||
if source_id == crate::extension::built_in::calculator::DATA_SOURCE_ID {
|
||||
continue;
|
||||
}
|
||||
|
||||
if title.contains(query) {
|
||||
score += 0.4;
|
||||
} else if title.to_lowercase().contains(&query_lower) {
|
||||
score += 0.2;
|
||||
}
|
||||
for hit in hits.iter_mut() {
|
||||
let document_title = hit.document.title.as_deref().unwrap_or("");
|
||||
let document_title_lowercase = document_title.to_lowercase();
|
||||
|
||||
let dist = levenshtein(&query_lower, &title.to_lowercase());
|
||||
let max_len = query_lower.len().max(title.len());
|
||||
if max_len > 0 {
|
||||
score += (1.0 - (dist as f64 / max_len as f64)) as f32;
|
||||
}
|
||||
let new_score = {
|
||||
let mut score = 0.0;
|
||||
|
||||
(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()
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ use tauri::AppHandle;
|
||||
#[allow(dead_code)]
|
||||
fn request_access_token_url(request_id: &str) -> String {
|
||||
// Remove the endpoint part and keep just the path for the request
|
||||
format!("/auth/request_access_token?request_id={}", request_id)
|
||||
format!("/auth/access_token?request_id={}", request_id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use tauri::{App, WebviewWindow};
|
||||
use tauri::{AppHandle, WebviewWindow};
|
||||
|
||||
pub fn platform(
|
||||
_app: &mut App,
|
||||
_tauri_app_handle: &AppHandle,
|
||||
_main_window: WebviewWindow,
|
||||
_settings_window: WebviewWindow,
|
||||
_check_window: WebviewWindow,
|
||||
|
||||
@@ -1,130 +1,67 @@
|
||||
//! credits to: https://github.com/ayangweb/ayangweb-EcoPaste/blob/169323dbe6365ffe4abb64d867439ed2ea84c6d1/src-tauri/src/core/setup/mac.rs
|
||||
|
||||
use cocoa::appkit::NSWindow;
|
||||
use tauri::Manager;
|
||||
use tauri::{App, AppHandle, Emitter, EventTarget, WebviewWindow};
|
||||
use tauri_nspanel::{WebviewWindowExt, cocoa::appkit::NSWindowCollectionBehavior, panel_delegate};
|
||||
|
||||
use crate::common::MAIN_WINDOW_LABEL;
|
||||
|
||||
#[allow(non_upper_case_globals)]
|
||||
const NSWindowStyleMaskNonActivatingPanel: i32 = 1 << 7;
|
||||
use tauri::{AppHandle, Emitter, EventTarget, Manager, WebviewWindow};
|
||||
use tauri_nspanel::{CollectionBehavior, PanelLevel, StyleMask, WebviewWindowExt, tauri_panel};
|
||||
|
||||
const WINDOW_FOCUS_EVENT: &str = "tauri://focus";
|
||||
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(
|
||||
_app: &mut App,
|
||||
_tauri_app_handle: &AppHandle,
|
||||
main_window: WebviewWindow,
|
||||
_settings_window: WebviewWindow,
|
||||
_check_window: WebviewWindow,
|
||||
) {
|
||||
// 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
|
||||
panel.set_level(20);
|
||||
// set level
|
||||
panel.set_level(PanelLevel::Utility.value());
|
||||
|
||||
// Do not steal focus from other windows
|
||||
panel.set_style_mask(NSWindowStyleMaskNonActivatingPanel);
|
||||
panel.set_style_mask(StyleMask::empty().nonactivating_panel().into());
|
||||
|
||||
// Share the window across all desktop spaces and full screen
|
||||
panel.set_collection_behaviour(
|
||||
NSWindowCollectionBehavior::NSWindowCollectionBehaviorMoveToActiveSpace
|
||||
| NSWindowCollectionBehavior::NSWindowCollectionBehaviorStationary
|
||||
| NSWindowCollectionBehavior::NSWindowCollectionBehaviorFullScreenAuxiliary,
|
||||
// Open the window in the active workspace and full screen
|
||||
panel.set_collection_behavior(
|
||||
CollectionBehavior::new()
|
||||
.stationary()
|
||||
.move_to_active_space()
|
||||
.full_screen_auxiliary()
|
||||
.into(),
|
||||
);
|
||||
|
||||
// Define the panel's delegate to listen to panel window events
|
||||
let delegate = panel_delegate!(EcoPanelDelegate {
|
||||
window_did_become_key,
|
||||
window_did_resign_key,
|
||||
window_did_resize,
|
||||
window_did_move
|
||||
});
|
||||
let handler = NsPanelEventHandler::new();
|
||||
|
||||
// Set event listeners for the delegate
|
||||
delegate.set_listener(Box::new(move |delegate_name: String| {
|
||||
let window = main_window.clone();
|
||||
handler.window_did_become_key(move |_| {
|
||||
let target = EventTarget::labeled(MAIN_WINDOW_LABEL);
|
||||
|
||||
let window_move_event = || {
|
||||
if let Ok(position) = main_window.outer_position() {
|
||||
let _ = main_window.emit_to(target.clone(), WINDOW_MOVED_EVENT, position);
|
||||
}
|
||||
};
|
||||
let _ = window.emit_to(target, WINDOW_FOCUS_EVENT, true);
|
||||
});
|
||||
|
||||
match delegate_name.as_str() {
|
||||
// Called when the window gets keyboard focus
|
||||
"window_did_become_key" => {
|
||||
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();
|
||||
let window = main_window.clone();
|
||||
handler.window_did_resign_key(move |_| {
|
||||
let target = EventTarget::labeled(MAIN_WINDOW_LABEL);
|
||||
|
||||
if let Ok(size) = main_window.inner_size() {
|
||||
let _ = main_window.emit_to(target, WINDOW_RESIZED_EVENT, size);
|
||||
}
|
||||
}
|
||||
// Called when the window position changes
|
||||
"window_did_move" => window_move_event(),
|
||||
_ => (),
|
||||
}
|
||||
}));
|
||||
let _ = window.emit_to(target, WINDOW_BLUR_EVENT, true);
|
||||
});
|
||||
|
||||
// Set the delegate object for the window to handle window events
|
||||
panel.set_delegate(delegate);
|
||||
}
|
||||
|
||||
/// Change NS window attribute between `NSWindowCollectionBehaviorCanJoinAllSpaces`
|
||||
/// and `NSWindowCollectionBehaviorMoveToActiveSpace` accordingly.
|
||||
///
|
||||
/// NOTE: this tauri command is not async because we should run it in the main
|
||||
/// thread, or `ns_window.setCollectionBehavior_(collection_behavior)` would lead
|
||||
/// to UB.
|
||||
#[tauri::command]
|
||||
pub(crate) fn toggle_move_to_active_space_attribute(tauri_app_hanlde: AppHandle) {
|
||||
use cocoa::appkit::NSWindowCollectionBehavior;
|
||||
use cocoa::base::id;
|
||||
|
||||
let main_window = tauri_app_hanlde
|
||||
.get_webview_window(MAIN_WINDOW_LABEL)
|
||||
.unwrap();
|
||||
let ns_window = main_window.ns_window().unwrap() as id;
|
||||
let mut collection_behavior = unsafe { ns_window.collectionBehavior() };
|
||||
let join_all_spaces = collection_behavior
|
||||
.contains(NSWindowCollectionBehavior::NSWindowCollectionBehaviorCanJoinAllSpaces);
|
||||
let move_to_active_space = collection_behavior
|
||||
.contains(NSWindowCollectionBehavior::NSWindowCollectionBehaviorMoveToActiveSpace);
|
||||
|
||||
match (join_all_spaces, move_to_active_space) {
|
||||
(true, false) => {
|
||||
collection_behavior
|
||||
.remove(NSWindowCollectionBehavior::NSWindowCollectionBehaviorCanJoinAllSpaces);
|
||||
collection_behavior
|
||||
.insert(NSWindowCollectionBehavior::NSWindowCollectionBehaviorMoveToActiveSpace);
|
||||
}
|
||||
(false, true) => {
|
||||
collection_behavior
|
||||
.remove(NSWindowCollectionBehavior::NSWindowCollectionBehaviorMoveToActiveSpace);
|
||||
collection_behavior
|
||||
.insert(NSWindowCollectionBehavior::NSWindowCollectionBehaviorCanJoinAllSpaces);
|
||||
}
|
||||
_ => {
|
||||
panic!(
|
||||
"invalid NS window attribute, NSWindowCollectionBehaviorCanJoinAllSpaces is set [{}], NSWindowCollectionBehaviorMoveToActiveSpace is set [{}]",
|
||||
join_all_spaces, move_to_active_space
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
unsafe {
|
||||
ns_window.setCollectionBehavior_(collection_behavior);
|
||||
}
|
||||
panel.set_event_handler(Some(handler.as_ref()));
|
||||
}
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
use tauri::{App, WebviewWindow};
|
||||
use crate::GLOBAL_TAURI_APP_HANDLE;
|
||||
use crate::autostart;
|
||||
use crate::common::register::SearchSourceRegistry;
|
||||
use crate::util::app_lang::update_app_lang;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::atomic::Ordering;
|
||||
use tauri::{AppHandle, Manager, WebviewWindow};
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod mac;
|
||||
@@ -19,7 +25,7 @@ pub use windows::*;
|
||||
pub use linux::*;
|
||||
|
||||
pub fn default(
|
||||
app: &mut App,
|
||||
tauri_app_handle: &AppHandle,
|
||||
main_window: WebviewWindow,
|
||||
settings_window: WebviewWindow,
|
||||
check_window: WebviewWindow,
|
||||
@@ -29,9 +35,83 @@ pub fn default(
|
||||
main_window.open_devtools();
|
||||
|
||||
platform(
|
||||
app,
|
||||
tauri_app_handle,
|
||||
main_window.clone(),
|
||||
settings_window.clone(),
|
||||
check_window.clone(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Indicates if the setup job is completed.
|
||||
static BACKEND_SETUP_COMPLETED: AtomicBool = AtomicBool::new(false);
|
||||
/// The function `backup_setup()` may be called concurrently, use this lock to
|
||||
/// synchronize that only 1 async task can do the actual setup job.
|
||||
static MUTEX_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
|
||||
|
||||
/// This function includes the setup job that has to be coordinated with the
|
||||
/// frontend, or the App will panic due to races[1]. The way we coordinate is to
|
||||
/// expose this function as a Tauri command, and let the frontend code invoke
|
||||
/// it.
|
||||
///
|
||||
/// The frontend code should ensure that:
|
||||
///
|
||||
/// 1. This command gets called before invoking other commands.
|
||||
/// 2. This command should only be called once.
|
||||
///
|
||||
/// [1]: For instance, Tauri command `list_extensions()` relies on an in-memory
|
||||
/// extension list that won't be initialized until `init_extensions()` gets
|
||||
/// called. If the frontend code invokes `list_extensions()` before `init_extension()`
|
||||
/// gets executed, we get a panic.
|
||||
#[tauri::command]
|
||||
pub(crate) async fn backend_setup(tauri_app_handle: AppHandle, app_lang: String) {
|
||||
if BACKEND_SETUP_COMPLETED.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Race to let one async task do the setup job
|
||||
let _guard = MUTEX_LOCK.lock().await;
|
||||
|
||||
// Re-check in case the current async task is not the first one that acquires
|
||||
// the lock
|
||||
if BACKEND_SETUP_COMPLETED.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
|
||||
GLOBAL_TAURI_APP_HANDLE
|
||||
.set(tauri_app_handle.clone())
|
||||
.expect("global tauri AppHandle already initialized");
|
||||
log::trace!("global Tauri AppHandle set");
|
||||
|
||||
let registry = SearchSourceRegistry::default();
|
||||
tauri_app_handle.manage(registry); // Store registry in Tauri's app state
|
||||
|
||||
// This has to be called before initializing extensions as doing that
|
||||
// requires access to the shortcut store, which will be set by this
|
||||
// function.
|
||||
//
|
||||
//
|
||||
// Windows requires that hotkey setup has to be done on the main thread, or
|
||||
// we will get error "ERROR_WINDOW_OF_OTHER_THREAD 1408 (0x580)"
|
||||
let tauri_app_handle_clone = tauri_app_handle.clone();
|
||||
tauri_app_handle
|
||||
.run_on_main_thread(move || {
|
||||
crate::shortcut::enable_shortcut(&tauri_app_handle_clone);
|
||||
})
|
||||
.expect("failed to run this closure on the main thread");
|
||||
|
||||
crate::init(&tauri_app_handle).await;
|
||||
|
||||
if let Err(err) = crate::extension::init_extensions(&tauri_app_handle).await {
|
||||
log::error!(
|
||||
"failed to initialize extension-related stuff, error [{}]",
|
||||
err
|
||||
);
|
||||
}
|
||||
|
||||
autostart::ensure_autostart_state_consistent(&tauri_app_handle).unwrap();
|
||||
|
||||
update_app_lang(app_lang).await;
|
||||
|
||||
// Invoked, now update the state
|
||||
BACKEND_SETUP_COMPLETED.store(true, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use tauri::{App, WebviewWindow};
|
||||
use tauri::{AppHandle, WebviewWindow};
|
||||
|
||||
pub fn platform(
|
||||
_app: &mut App,
|
||||
_tauri_app_handle: &AppHandle,
|
||||
_main_window: WebviewWindow,
|
||||
_settings_window: WebviewWindow,
|
||||
_check_window: WebviewWindow,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::common::MAIN_WINDOW_LABEL;
|
||||
use crate::{COCO_TAURI_STORE, hide_coco, show_coco};
|
||||
use tauri::{App, AppHandle, Manager, async_runtime};
|
||||
use tauri::{AppHandle, Manager, async_runtime};
|
||||
use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState};
|
||||
use tauri_plugin_store::{JsonValue, StoreExt};
|
||||
|
||||
@@ -16,9 +17,9 @@ const DEFAULT_SHORTCUT: &str = "command+shift+space";
|
||||
const DEFAULT_SHORTCUT: &str = "ctrl+shift+space";
|
||||
|
||||
/// Set up the shortcut upon app start.
|
||||
pub fn enable_shortcut(app: &App) {
|
||||
pub fn enable_shortcut(tauri_app_handle: &AppHandle) {
|
||||
log::trace!("setting up Coco hotkey");
|
||||
let store = app
|
||||
let store = tauri_app_handle
|
||||
.store(COCO_TAURI_STORE)
|
||||
.expect("creating a store should not fail");
|
||||
|
||||
@@ -33,7 +34,7 @@ pub fn enable_shortcut(app: &App) {
|
||||
let stored_shortcut = stored_shortcut_str
|
||||
.parse::<Shortcut>()
|
||||
.expect("stored shortcut string should be valid");
|
||||
_register_shortcut_upon_start(app, stored_shortcut);
|
||||
_register_shortcut_upon_start(tauri_app_handle, stored_shortcut);
|
||||
} else {
|
||||
store.set(
|
||||
COCO_GLOBAL_SHORTCUT,
|
||||
@@ -42,7 +43,7 @@ pub fn enable_shortcut(app: &App) {
|
||||
let default_shortcut = DEFAULT_SHORTCUT
|
||||
.parse::<Shortcut>()
|
||||
.expect("default shortcut should never be invalid");
|
||||
_register_shortcut_upon_start(app, default_shortcut);
|
||||
_register_shortcut_upon_start(tauri_app_handle, default_shortcut);
|
||||
}
|
||||
log::trace!("Coco hotkey has been set");
|
||||
}
|
||||
@@ -118,12 +119,9 @@ fn _register_shortcut(app: &AppHandle, shortcut: Shortcut) {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
use crate::common::MAIN_WINDOW_LABEL;
|
||||
|
||||
/// Helper function to register a shortcut, used to set up the shortcut up App's first start.
|
||||
fn _register_shortcut_upon_start(app: &App, shortcut: Shortcut) {
|
||||
let handler = app.app_handle();
|
||||
handler
|
||||
fn _register_shortcut_upon_start(tauri_app_handle: &AppHandle, shortcut: Shortcut) {
|
||||
tauri_app_handle
|
||||
.plugin(
|
||||
tauri_plugin_global_shortcut::Builder::new()
|
||||
.with_handler(move |app, scut, event| {
|
||||
@@ -147,7 +145,10 @@ fn _register_shortcut_upon_start(app: &App, shortcut: Shortcut) {
|
||||
.build(),
|
||||
)
|
||||
.unwrap();
|
||||
app.global_shortcut().register(shortcut).unwrap();
|
||||
tauri_app_handle
|
||||
.global_shortcut()
|
||||
.register(shortcut)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// Helper function to get the stored global shortcut, as a string.
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
//! So we duplicate it here **in the MEMORY** and expose a setter method to the
|
||||
//! frontend so that the value can be updated and stay update-to-date.
|
||||
|
||||
use function_name::named;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
@@ -23,6 +22,10 @@ impl std::fmt::Display for Lang {
|
||||
}
|
||||
}
|
||||
|
||||
/// Frontend code uses "en" and "zh" to represent the Application language.
|
||||
///
|
||||
/// This impl is not meant to be used as a parser for locale strings such as
|
||||
/// "en_US" or "zh_CN".
|
||||
impl std::str::FromStr for Lang {
|
||||
type Err = String;
|
||||
|
||||
@@ -38,16 +41,13 @@ impl std::str::FromStr for Lang {
|
||||
/// Cache the language config in memory.
|
||||
static APP_LANG: RwLock<Option<Lang>> = RwLock::const_new(None);
|
||||
|
||||
/// Frontend code uses this interface to update the in-memory cached `APP_LANG` config.
|
||||
#[named]
|
||||
/// Update the in-memory cached `APP_LANG` config.
|
||||
#[tauri::command]
|
||||
pub(crate) async fn update_app_lang(lang: String) {
|
||||
let app_lang = lang.parse::<Lang>().unwrap_or_else(|e| {
|
||||
panic!(
|
||||
"frontend code passes an invalid argument [{}] to interface [{}], parsing error [{}]",
|
||||
lang,
|
||||
function_name!(),
|
||||
e
|
||||
"invalid argument [{}], could not parse it to [struct Lang], parsing error [{}]",
|
||||
lang, e
|
||||
)
|
||||
});
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ pub(crate) enum FileType {
|
||||
Unknown,
|
||||
}
|
||||
|
||||
async fn get_file_type(path: &str) -> FileType {
|
||||
fn get_file_type(path: &str) -> FileType {
|
||||
let path = camino::Utf8Path::new(path);
|
||||
|
||||
// stat() is more precise than file extension, use it if possible.
|
||||
@@ -167,8 +167,13 @@ fn type_to_icon(ty: FileType) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn get_file_icon(path: String) -> &'static str {
|
||||
let ty = get_file_type(path.as_str()).await;
|
||||
/// Synchronous version of `get_file_icon()`.
|
||||
pub(crate) fn sync_get_file_icon(path: &str) -> &'static str {
|
||||
let ty = get_file_type(path);
|
||||
type_to_icon(ty)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn get_file_icon(path: String) -> &'static str {
|
||||
sync_get_file_icon(&path)
|
||||
}
|
||||
|
||||
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,6 +1,11 @@
|
||||
pub(crate) mod app_lang;
|
||||
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 prevent_default;
|
||||
pub(crate) mod system_lang;
|
||||
pub(crate) mod updater;
|
||||
|
||||
use std::{path::Path, process::Command};
|
||||
@@ -11,7 +16,7 @@ use tauri_plugin_shell::ShellExt;
|
||||
const XDG_CURRENT_DESKTOP: &str = "XDG_CURRENT_DESKTOP";
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
enum LinuxDesktopEnvironment {
|
||||
pub(crate) enum LinuxDesktopEnvironment {
|
||||
Gnome,
|
||||
Kde,
|
||||
Unsupported { xdg_current_desktop: String },
|
||||
@@ -63,7 +68,7 @@ impl LinuxDesktopEnvironment {
|
||||
}
|
||||
|
||||
/// None means that it is likely that we do not have a desktop environment.
|
||||
fn get_linux_desktop_environment() -> Option<LinuxDesktopEnvironment> {
|
||||
pub(crate) fn get_linux_desktop_environment() -> Option<LinuxDesktopEnvironment> {
|
||||
let de_os_str = std::env::var_os(XDG_CURRENT_DESKTOP)?;
|
||||
let de_str = de_os_str.into_string().unwrap_or_else(|_os_string| {
|
||||
panic!("${} should be UTF-8 encoded", XDG_CURRENT_DESKTOP);
|
||||
|
||||
12
src-tauri/src/util/path.rs
Normal file
12
src-tauri/src/util/path.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
#[tauri::command]
|
||||
pub(crate) fn path_absolute(path: &str) -> String {
|
||||
// We do not use std::path::absolute() because it does not clean ".."
|
||||
// https://doc.rust-lang.org/stable/std/path/fn.absolute.html#platform-specific-behavior
|
||||
use path_clean::clean;
|
||||
|
||||
let clean_path = clean(path);
|
||||
clean_path
|
||||
.into_os_string()
|
||||
.into_string()
|
||||
.expect("path should be UTF-8 encoded")
|
||||
}
|
||||
@@ -54,7 +54,6 @@ impl Platform {
|
||||
}
|
||||
|
||||
/// Returns a set that contains all the platforms.
|
||||
#[cfg(test)] // currently, only used in tests
|
||||
pub(crate) fn all() -> std::collections::HashSet<Self> {
|
||||
Platform::VARIANTS.into_iter().copied().collect()
|
||||
}
|
||||
|
||||
13
src-tauri/src/util/prevent_default.rs
Normal file
13
src-tauri/src/util/prevent_default.rs
Normal file
@@ -0,0 +1,13 @@
|
||||
pub fn init() -> tauri::plugin::TauriPlugin<tauri::Wry> {
|
||||
#[cfg(debug_assertions)]
|
||||
{
|
||||
use tauri_plugin_prevent_default::Flags;
|
||||
|
||||
tauri_plugin_prevent_default::Builder::new()
|
||||
.with_flags(Flags::all().difference(Flags::CONTEXT_MENU))
|
||||
.build()
|
||||
}
|
||||
|
||||
#[cfg(not(debug_assertions))]
|
||||
tauri_plugin_prevent_default::init()
|
||||
}
|
||||
14
src-tauri/src/util/system_lang.rs
Normal file
14
src-tauri/src/util/system_lang.rs
Normal file
@@ -0,0 +1,14 @@
|
||||
/// Helper function to get the system language.
|
||||
///
|
||||
/// We cannot return `enum Lang` here because Coco has limited language support
|
||||
/// but the OS supports many more languages.
|
||||
#[cfg(feature = "use_pizza_engine")]
|
||||
pub(crate) fn get_system_lang() -> String {
|
||||
use sys_locale::get_locale;
|
||||
|
||||
// fall back to English (general) when we cannot get the locale
|
||||
//
|
||||
// We replace '-' with '_' in applications-rs, to make the locales match,
|
||||
// we need to do this here as well.
|
||||
get_locale().unwrap_or("en".into()).replace('-', "_")
|
||||
}
|
||||
@@ -129,7 +129,6 @@
|
||||
"shell": {},
|
||||
"globalShortcut": {},
|
||||
"deep-link": {
|
||||
"schema": "coco",
|
||||
"mobile": [
|
||||
{
|
||||
"host": "app.infini.cloud",
|
||||
|
||||
8
src-tauri/tauri.macos.conf.json
Normal file
8
src-tauri/tauri.macos.conf.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"identifier": "rs.coco.app",
|
||||
"bundle": {
|
||||
"macOS": {
|
||||
"entitlements": "./Entitlements.plist"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import axios from "axios";
|
||||
|
||||
axios.defaults.withCredentials = true;
|
||||
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
|
||||
import {
|
||||
@@ -78,7 +80,7 @@ export const Get = <T>(
|
||||
}
|
||||
|
||||
axios
|
||||
.get(baseURL + url, { params })
|
||||
.get(baseURL + url, { params, withCredentials: true })
|
||||
.then((result) => {
|
||||
let res: FcResponse<T>;
|
||||
if (clearFn !== undefined) {
|
||||
@@ -86,11 +88,6 @@ export const Get = <T>(
|
||||
} else {
|
||||
res = result?.data as FcResponse<T>;
|
||||
}
|
||||
// web component log
|
||||
infoLog({
|
||||
username: "@/api/axiosRequest.ts",
|
||||
logName: url,
|
||||
})(res);
|
||||
|
||||
resolve([null, res as FcResponse<T>]);
|
||||
})
|
||||
@@ -115,10 +112,15 @@ export const Post = <T>(
|
||||
}
|
||||
|
||||
axios
|
||||
.post(baseURL + url, data, {
|
||||
params,
|
||||
headers,
|
||||
} as any)
|
||||
.post(
|
||||
baseURL + url,
|
||||
data,
|
||||
{
|
||||
params,
|
||||
headers,
|
||||
withCredentials: true,
|
||||
} as any
|
||||
)
|
||||
.then((result) => {
|
||||
resolve([null, result.data as FcResponse<T>]);
|
||||
})
|
||||
|
||||
@@ -34,6 +34,7 @@ export async function streamPost({
|
||||
...(headersStorage),
|
||||
...(headers || {}),
|
||||
},
|
||||
credentials: "include",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
|
||||
@@ -15,50 +15,10 @@ import {
|
||||
MultiSourceQueryResponse,
|
||||
} from "@/types/commands";
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
import { useAuthStore } from "@/stores/authStore";
|
||||
import { useConnectStore } from "@/stores/connectStore";
|
||||
import { SETTINGS_WINDOW_LABEL } from "@/constants";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
|
||||
export async function getCurrentWindowService() {
|
||||
const currentService = useConnectStore.getState().currentService;
|
||||
const cloudSelectService = useConnectStore.getState().cloudSelectService;
|
||||
const windowLabel = await platformAdapter.getCurrentWindowLabel();
|
||||
|
||||
return windowLabel === SETTINGS_WINDOW_LABEL
|
||||
? cloudSelectService
|
||||
: currentService;
|
||||
}
|
||||
|
||||
export async function setCurrentWindowService(service: any) {
|
||||
const windowLabel = await platformAdapter.getCurrentWindowLabel();
|
||||
const { setCurrentService, setCloudSelectService } =
|
||||
useConnectStore.getState();
|
||||
|
||||
return windowLabel === SETTINGS_WINDOW_LABEL
|
||||
? setCloudSelectService(service)
|
||||
: setCurrentService(service);
|
||||
}
|
||||
|
||||
export async function handleLogout(serverId?: string) {
|
||||
const setIsCurrentLogin = useAuthStore.getState().setIsCurrentLogin;
|
||||
const { serverList, setServerList } = useConnectStore.getState();
|
||||
|
||||
const service = await getCurrentWindowService();
|
||||
|
||||
const id = serverId || service?.id;
|
||||
if (!id) return;
|
||||
|
||||
// Update the status first
|
||||
setIsCurrentLogin(false);
|
||||
if (service?.id === id) {
|
||||
await setCurrentWindowService({ ...service, profile: null });
|
||||
}
|
||||
const updatedServerList = serverList.map((server) =>
|
||||
server.id === id ? { ...server, profile: null } : server
|
||||
);
|
||||
setServerList(updatedServerList);
|
||||
}
|
||||
import {
|
||||
getCurrentWindowService,
|
||||
handleLogout,
|
||||
} from "@/commands/windowService";
|
||||
|
||||
// Endpoints that don't require authentication
|
||||
const WHITELIST_SERVERS = [
|
||||
@@ -78,16 +38,9 @@ async function invokeWithErrorHandler<T>(
|
||||
command: string,
|
||||
args?: Record<string, any>
|
||||
): Promise<T> {
|
||||
const isCurrentLogin = useAuthStore.getState().isCurrentLogin;
|
||||
|
||||
const service = await getCurrentWindowService();
|
||||
|
||||
// Not logged in
|
||||
// console.log("isCurrentLogin", command, isCurrentLogin);
|
||||
if (
|
||||
!WHITELIST_SERVERS.includes(command) &&
|
||||
(!isCurrentLogin || !service?.profile)
|
||||
) {
|
||||
if (!WHITELIST_SERVERS.includes(command) && !service?.profile) {
|
||||
console.error("This command requires authentication");
|
||||
throw new Error("This command requires authentication");
|
||||
}
|
||||
|
||||
@@ -34,8 +34,4 @@ export function show_check(): Promise<void> {
|
||||
|
||||
export function hide_check(): Promise<void> {
|
||||
return invoke('hide_check');
|
||||
}
|
||||
|
||||
export function toggle_move_to_active_space_attribute(): Promise<void> {
|
||||
return invoke('toggle_move_to_active_space_attribute');
|
||||
}
|
||||
73
src/commands/windowService.ts
Normal file
73
src/commands/windowService.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { useConnectStore } from "@/stores/connectStore";
|
||||
import { SETTINGS_WINDOW_LABEL } from "@/constants";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
import { useAuthStore } from "@/stores/authStore";
|
||||
import { useExtensionsStore } from "@/stores/extensionsStore";
|
||||
|
||||
export async function getCurrentWindowService() {
|
||||
const currentService = useConnectStore.getState().currentService;
|
||||
const cloudSelectService = useConnectStore.getState().cloudSelectService;
|
||||
const windowLabel = await platformAdapter.getCurrentWindowLabel();
|
||||
|
||||
return windowLabel === SETTINGS_WINDOW_LABEL
|
||||
? cloudSelectService
|
||||
: currentService;
|
||||
}
|
||||
|
||||
export async function setCurrentWindowService(service: any, isAll?: boolean) {
|
||||
const { setCurrentService, setCloudSelectService } =
|
||||
useConnectStore.getState();
|
||||
// all refresh logout
|
||||
if (isAll) {
|
||||
setCloudSelectService(service);
|
||||
return setCurrentService(service);
|
||||
}
|
||||
// current refresh
|
||||
const windowLabel = await platformAdapter.getCurrentWindowLabel();
|
||||
|
||||
if (windowLabel === SETTINGS_WINDOW_LABEL) {
|
||||
const { currentService } = useConnectStore.getState();
|
||||
const {
|
||||
aiOverviewServer,
|
||||
setAiOverviewServer,
|
||||
quickAiAccessServer,
|
||||
setQuickAiAccessServer,
|
||||
} = useExtensionsStore.getState();
|
||||
|
||||
if (currentService?.id === service.id) {
|
||||
setCurrentService(service);
|
||||
}
|
||||
|
||||
if (aiOverviewServer?.id === service.id) {
|
||||
setAiOverviewServer(service);
|
||||
}
|
||||
|
||||
if (quickAiAccessServer?.id === service.id) {
|
||||
setQuickAiAccessServer(service);
|
||||
}
|
||||
|
||||
return setCloudSelectService(service);
|
||||
}
|
||||
|
||||
return setCurrentService(service);
|
||||
}
|
||||
|
||||
export async function handleLogout(serverId?: string) {
|
||||
const setIsCurrentLogin = useAuthStore.getState().setIsCurrentLogin;
|
||||
const { serverList, setServerList } = useConnectStore.getState();
|
||||
|
||||
const service = await getCurrentWindowService();
|
||||
|
||||
const id = serverId || service?.id;
|
||||
if (!id) return;
|
||||
|
||||
// Update the status first
|
||||
setIsCurrentLogin(false);
|
||||
if (service?.id === id) {
|
||||
await setCurrentWindowService({ ...service, profile: null }, true);
|
||||
}
|
||||
const updatedServerList = serverList.map((server) =>
|
||||
server.id === id ? { ...server, profile: null } : server
|
||||
);
|
||||
setServerList(updatedServerList);
|
||||
}
|
||||
@@ -25,7 +25,7 @@ export const AssistantFetcher = ({
|
||||
query?: string;
|
||||
}) => {
|
||||
try {
|
||||
if (unrequitable()) {
|
||||
if (await unrequitable()) {
|
||||
return {
|
||||
total: 0,
|
||||
list: [],
|
||||
|
||||
@@ -30,19 +30,23 @@ export function AssistantList({ assistantIDs = [] }: AssistantListProps) {
|
||||
const setCurrentAssistant = useConnectStore((state) => {
|
||||
return state.setCurrentAssistant;
|
||||
});
|
||||
const assistantList = useConnectStore((state) => state.assistantList);
|
||||
|
||||
const aiAssistant = useShortcutsStore((state) => state.aiAssistant);
|
||||
|
||||
const [assistants, setAssistants] = useState<any[]>([]);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
const popoverButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const [keyword, setKeyword] = useState("");
|
||||
|
||||
const debounceKeyword = useDebounce(keyword, { wait: 500 });
|
||||
|
||||
const askAiAssistantId = useSearchStore((state) => state.askAiAssistantId);
|
||||
const setAskAiAssistantId = useSearchStore((state) => {
|
||||
return state.setAskAiAssistantId;
|
||||
});
|
||||
const assistantList = useConnectStore((state) => state.assistantList);
|
||||
|
||||
const { fetchAssistant } = AssistantFetcher({
|
||||
debounceKeyword,
|
||||
|
||||
@@ -41,6 +41,7 @@ interface ChatAIProps {
|
||||
startPage?: StartPage;
|
||||
formatUrl?: (data: any) => string;
|
||||
instanceId?: string;
|
||||
getChatHistoryChatPage?: () => void;
|
||||
}
|
||||
|
||||
export interface SendMessageParams {
|
||||
@@ -52,6 +53,7 @@ export interface ChatAIRef {
|
||||
init: (params: SendMessageParams) => void;
|
||||
cancelChat: () => void;
|
||||
clearChat: () => void;
|
||||
onSelectChat: (chat: Chat) => void;
|
||||
}
|
||||
|
||||
const ChatAI = memo(
|
||||
@@ -73,6 +75,7 @@ const ChatAI = memo(
|
||||
startPage,
|
||||
formatUrl,
|
||||
instanceId,
|
||||
getChatHistoryChatPage,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
@@ -80,6 +83,7 @@ const ChatAI = memo(
|
||||
init: init,
|
||||
cancelChat: () => cancelChat(activeChat),
|
||||
clearChat: clearChat,
|
||||
onSelectChat: onSelectChat,
|
||||
}));
|
||||
|
||||
const curChatEnd = useChatStore((state) => state.curChatEnd);
|
||||
@@ -193,7 +197,8 @@ const ChatAI = memo(
|
||||
isDeepThinkActive,
|
||||
isMCPActive,
|
||||
changeInput,
|
||||
showChatHistory
|
||||
showChatHistory,
|
||||
getChatHistoryChatPage,
|
||||
);
|
||||
|
||||
const { dealMsg } = useMessageHandler(
|
||||
@@ -233,7 +238,7 @@ const ChatAI = memo(
|
||||
async (params: SendMessageParams) => {
|
||||
try {
|
||||
//console.log("init", curChatEnd, activeChat?._id);
|
||||
if (!isCurrentLogin) {
|
||||
if (isTauri && !isCurrentLogin) {
|
||||
addError("Please login to continue chatting");
|
||||
return;
|
||||
}
|
||||
@@ -390,7 +395,7 @@ const ChatAI = memo(
|
||||
assistantIDs={assistantIDs}
|
||||
/>
|
||||
|
||||
{isCurrentLogin ? (
|
||||
{!isTauri || (isTauri && isCurrentLogin) ? (
|
||||
<>
|
||||
<ChatContent
|
||||
activeChat={activeChat}
|
||||
|
||||
@@ -5,7 +5,6 @@ import { ChatMessage } from "@/components/ChatMessage";
|
||||
import { Greetings } from "./Greetings";
|
||||
import AttachmentList from "@/components/Assistant/AttachmentList";
|
||||
import { useChatScroll } from "@/hooks/useChatScroll";
|
||||
|
||||
import type { Chat, IChunkData } from "@/types/chat";
|
||||
import { useConnectStore } from "@/stores/connectStore";
|
||||
// import SessionFile from "./SessionFile";
|
||||
@@ -45,20 +44,23 @@ export const ChatContent = ({
|
||||
handleSendMessage,
|
||||
formatUrl,
|
||||
}: ChatContentProps) => {
|
||||
const { currentSessionId, setCurrentSessionId } = useConnectStore();
|
||||
|
||||
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 { scrollToBottom } = useChatScroll(messagesEndRef);
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [isAtBottom, setIsAtBottom] = useState(true);
|
||||
const visibleStartPage = useConnectStore((state) => state.visibleStartPage);
|
||||
|
||||
const curChatEnd = useChatStore((state) => state.curChatEnd);
|
||||
|
||||
useEffect(() => {
|
||||
setIsAtBottom(true);
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { MessageSquarePlus } from "lucide-react";
|
||||
import clsx from "clsx";
|
||||
|
||||
import HistoryIcon from "@/icons/History";
|
||||
import PinOffIcon from "@/icons/PinOff";
|
||||
import PinIcon from "@/icons/Pin";
|
||||
import WindowsFullIcon from "@/icons/WindowsFull";
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
import type { Chat } from "@/types/chat";
|
||||
@@ -12,7 +9,7 @@ import { useShortcutsStore } from "@/stores/shortcutsStore";
|
||||
import { HISTORY_PANEL_ID } from "@/constants";
|
||||
import { AssistantList } from "./AssistantList";
|
||||
import { ServerList } from "./ServerList";
|
||||
import { useTogglePin } from "@/hooks/useTogglePin";
|
||||
import TogglePin from "../Common/TogglePin";
|
||||
|
||||
interface ChatHeaderProps {
|
||||
clearChat: () => void;
|
||||
@@ -36,10 +33,8 @@ export function ChatHeader({
|
||||
assistantIDs,
|
||||
}: ChatHeaderProps) {
|
||||
const { isTauri } = useAppStore();
|
||||
const { isPinned, togglePin } = useTogglePin();
|
||||
|
||||
const { historicalRecords, newSession, fixedWindow, external } =
|
||||
useShortcutsStore();
|
||||
const { historicalRecords, newSession, external } = useShortcutsStore();
|
||||
|
||||
return (
|
||||
<header
|
||||
@@ -92,16 +87,7 @@ export function ChatHeader({
|
||||
|
||||
{isTauri ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={togglePin}
|
||||
className={clsx("inline-flex", {
|
||||
"text-blue-500": isPinned,
|
||||
})}
|
||||
>
|
||||
<VisibleKey shortcut={fixedWindow} onKeyPress={togglePin}>
|
||||
{isPinned ? <PinIcon /> : <PinOffIcon />}
|
||||
</VisibleKey>
|
||||
</button>
|
||||
<TogglePin className="inline-flex" />
|
||||
|
||||
<ServerList clearChat={clearChat} />
|
||||
|
||||
|
||||
@@ -18,7 +18,10 @@ import StatusIndicator from "@/components/Cloud/StatusIndicator";
|
||||
import { useAuthStore } from "@/stores/authStore";
|
||||
import { useSearchStore } from "@/stores/searchStore";
|
||||
import { useServers } from "@/hooks/useServers";
|
||||
import { getCurrentWindowService, setCurrentWindowService } from "@/commands";
|
||||
import {
|
||||
getCurrentWindowService,
|
||||
setCurrentWindowService,
|
||||
} from "@/commands/windowService";
|
||||
|
||||
interface ServerListProps {
|
||||
clearChat: () => void;
|
||||
@@ -33,10 +36,9 @@ export function ServerList({ clearChat }: ServerListProps) {
|
||||
);
|
||||
const setEndpoint = useAppStore((state) => state.setEndpoint);
|
||||
const isTauri = useAppStore((state) => state.isTauri);
|
||||
|
||||
const currentService = useConnectStore((state) => state.currentService);
|
||||
const cloudSelectService = useConnectStore((state) => {
|
||||
return state.cloudSelectService;
|
||||
});
|
||||
const serverList = useConnectStore((state) => state.serverList);
|
||||
|
||||
const { setMessages } = useChatStore();
|
||||
|
||||
@@ -55,7 +57,6 @@ export function ServerList({ clearChat }: ServerListProps) {
|
||||
const serverListButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const { refreshServerList } = useServers();
|
||||
const serverList = useConnectStore((state) => state.serverList);
|
||||
|
||||
const switchServer = async (server: IServer) => {
|
||||
if (!server) return;
|
||||
@@ -95,8 +96,10 @@ export function ServerList({ clearChat }: ServerListProps) {
|
||||
} else {
|
||||
switchServer(enabledServers[enabledServers.length - 1]);
|
||||
}
|
||||
} else {
|
||||
setCurrentWindowService({});
|
||||
}
|
||||
}, [currentService?.id, cloudSelectService?.id, serverList]);
|
||||
}, [serverList]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!askAiServerId || serverList.length === 0) return;
|
||||
@@ -229,11 +232,11 @@ export function ServerList({ clearChat }: ServerListProps) {
|
||||
: "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
|
||||
src={server?.provider?.icon || logoImg}
|
||||
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) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.src = logoImg;
|
||||
|
||||
@@ -37,8 +37,6 @@ const SessionFile = (props: SessionFileProps) => {
|
||||
|
||||
const getUploadedFiles = async () => {
|
||||
if (isTauri) {
|
||||
console.log("sessionId", sessionId);
|
||||
|
||||
const response: any = await platformAdapter.commands(
|
||||
"get_attachment_by_ids",
|
||||
{
|
||||
|
||||
@@ -54,7 +54,7 @@ const Splash = ({ assistantIDs = [], startPage }: SplashProps) => {
|
||||
|
||||
let response: any;
|
||||
if (isTauri) {
|
||||
if (unrequitable()) {
|
||||
if (await unrequitable()) {
|
||||
return setVisibleStartPage(false);
|
||||
}
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ export const MessageActions = ({
|
||||
};
|
||||
|
||||
const handleSpeak = async () => {
|
||||
if (isDefaultServer()) {
|
||||
if (await isDefaultServer()) {
|
||||
return setSynthesizeItem({ id, content });
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useEffect, useRef, useState, useCallback } from "react";
|
||||
import { isEqual } from "lodash-es";
|
||||
import { usePrevious } from "ahooks";
|
||||
|
||||
import { DataSourcesList } from "./DataSourcesList";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
@@ -7,9 +9,9 @@ import { useAppStore } from "@/stores/appStore";
|
||||
import { useConnectStore } from "@/stores/connectStore";
|
||||
import ServiceInfo from "./ServiceInfo";
|
||||
import ServiceAuth from "./ServiceAuth";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
import type { Server } from "@/types/server";
|
||||
import { useServers } from "@/hooks/useServers";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
|
||||
export default function Cloud() {
|
||||
const SidebarRef = useRef<{ refreshData: () => void }>(null);
|
||||
@@ -24,6 +26,7 @@ export default function Cloud() {
|
||||
serverList,
|
||||
setServerList,
|
||||
} = useConnectStore();
|
||||
const prevServerList = usePrevious(serverList);
|
||||
|
||||
const [refreshLoading, setRefreshLoading] = useState(false);
|
||||
|
||||
@@ -31,6 +34,8 @@ export default function Cloud() {
|
||||
|
||||
// fetch the servers
|
||||
useEffect(() => {
|
||||
if (isEqual(prevServerList, serverList)) return;
|
||||
|
||||
fetchServers();
|
||||
}, [serverList]);
|
||||
|
||||
@@ -40,39 +45,45 @@ export default function Cloud() {
|
||||
}, [cloudSelectService?.id]);
|
||||
|
||||
const fetchServers = useCallback(async () => {
|
||||
let res = serverList;
|
||||
let { serverList } = useConnectStore.getState();
|
||||
|
||||
if (errors.length > 0) {
|
||||
res = res.map((item: Server) => {
|
||||
serverList = serverList.map((item: Server) => {
|
||||
if (item.id === cloudSelectService?.id) {
|
||||
item.health = {
|
||||
services: item.health?.services || {},
|
||||
status: item.health?.status || "red",
|
||||
return {
|
||||
...item,
|
||||
health: {
|
||||
services: item.health?.services || {},
|
||||
status: item.health?.status || "red",
|
||||
},
|
||||
};
|
||||
}
|
||||
return item;
|
||||
});
|
||||
}
|
||||
setServerList(res);
|
||||
|
||||
if (res.length > 0) {
|
||||
const matched = res.find((server: any) => {
|
||||
setServerList(serverList);
|
||||
|
||||
if (serverList.length > 0) {
|
||||
const matched = serverList.find((server: any) => {
|
||||
return server.id === cloudSelectService?.id;
|
||||
});
|
||||
|
||||
if (matched) {
|
||||
setCloudSelectService(matched);
|
||||
} else {
|
||||
setCloudSelectService(res[res.length - 1]);
|
||||
setCloudSelectService(serverList[serverList.length - 1]);
|
||||
}
|
||||
}
|
||||
}, [serverList, errors, cloudSelectService]);
|
||||
}, [errors, cloudSelectService]);
|
||||
|
||||
const refreshClick = useCallback(
|
||||
async (id: string) => {
|
||||
async (id: string, callback?: () => void) => {
|
||||
setRefreshLoading(true);
|
||||
await platformAdapter.commands("refresh_coco_server_info", id);
|
||||
await refreshServerList();
|
||||
setRefreshLoading(false);
|
||||
callback && callback();
|
||||
},
|
||||
[refreshServerList]
|
||||
);
|
||||
|
||||
@@ -59,7 +59,7 @@ export function DataSourceItem({ name, icon, connector }: DataSourceItemProps) {
|
||||
{icon?.startsWith("font_") ? (
|
||||
<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">
|
||||
|
||||
@@ -2,11 +2,6 @@ import { FC, memo, useCallback, useEffect, useState } from "react";
|
||||
import { Copy } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import {
|
||||
getCurrent as getCurrentDeepLinkUrls,
|
||||
onOpenUrl,
|
||||
} from "@tauri-apps/plugin-deep-link";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
|
||||
import { UserProfile } from "./UserProfile";
|
||||
import { OpenURLWithBrowser } from "@/utils";
|
||||
@@ -18,19 +13,21 @@ import { useServers } from "@/hooks/useServers";
|
||||
|
||||
interface ServiceAuthProps {
|
||||
setRefreshLoading: (loading: boolean) => void;
|
||||
refreshClick: (id: string) => void;
|
||||
refreshClick: (id: string, callback?: () => void) => void;
|
||||
}
|
||||
|
||||
const ServiceAuth = memo(
|
||||
({ setRefreshLoading, refreshClick }: ServiceAuthProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const language = useAppStore((state) => state.language);
|
||||
const addError = useAppStore((state) => state.addError);
|
||||
const ssoRequestID = useAppStore((state) => state.ssoRequestID);
|
||||
const setSSORequestID = useAppStore((state) => state.setSSORequestID);
|
||||
|
||||
const addError = useAppStore((state) => state.addError);
|
||||
|
||||
const cloudSelectService = useConnectStore((state) => state.cloudSelectService);
|
||||
const cloudSelectService = useConnectStore(
|
||||
(state) => state.cloudSelectService
|
||||
);
|
||||
|
||||
const { logoutServer } = useServers();
|
||||
|
||||
@@ -45,7 +42,7 @@ const ServiceAuth = memo(
|
||||
// Generate the login URL with the current appUid
|
||||
const url = `${cloudSelectService?.auth_provider?.sso?.url}/?provider=${cloudSelectService?.id}&product=coco&request_id=${requestID}`;
|
||||
|
||||
console.log("Open SSO link, requestID:", ssoRequestID, url);
|
||||
console.log("Open SSO link, requestID:", url);
|
||||
|
||||
// Open the URL in a browser
|
||||
OpenURLWithBrowser(url);
|
||||
@@ -64,100 +61,25 @@ const ServiceAuth = memo(
|
||||
[logoutServer]
|
||||
);
|
||||
|
||||
const handleOAuthCallback = useCallback(
|
||||
async (code: string | null, serverId: string | null) => {
|
||||
if (!code || !serverId) {
|
||||
addError("No authorization code received");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log("Handling OAuth callback:", { code, serverId });
|
||||
await platformAdapter.commands("handle_sso_callback", {
|
||||
serverId: serverId, // Make sure 'server_id' is the correct argument
|
||||
requestId: ssoRequestID, // Make sure 'request_id' is the correct argument
|
||||
code: code,
|
||||
});
|
||||
|
||||
if (serverId != null) {
|
||||
refreshClick(serverId);
|
||||
}
|
||||
|
||||
getCurrentWindow().setFocus();
|
||||
} catch (e) {
|
||||
console.error("Sign in failed:", e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[ssoRequestID]
|
||||
);
|
||||
|
||||
const handleUrl = (url: string) => {
|
||||
try {
|
||||
const urlObject = new URL(url.trim());
|
||||
console.log("handle urlObject:", urlObject);
|
||||
|
||||
// pass request_id and check with local, if the request_id are same, then continue
|
||||
const reqId = urlObject.searchParams.get("request_id");
|
||||
const code = urlObject.searchParams.get("code");
|
||||
|
||||
if (reqId != ssoRequestID) {
|
||||
console.log("Request ID not matched, skip");
|
||||
addError("Request ID not matched, skip");
|
||||
return;
|
||||
}
|
||||
|
||||
const serverId = cloudSelectService?.id;
|
||||
handleOAuthCallback(code, serverId);
|
||||
} catch (err) {
|
||||
console.error("Failed to parse URL:", err);
|
||||
addError("Invalid URL format: " + err);
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch the initial deep link intent
|
||||
// handle oauth success event
|
||||
useEffect(() => {
|
||||
// Function to handle pasted URL
|
||||
const handlePaste = (event: any) => {
|
||||
const pastedText = event.clipboardData.getData("text").trim();
|
||||
console.log("handle paste text:", pastedText);
|
||||
if (isValidCallbackUrl(pastedText)) {
|
||||
// Handle the URL as if it's a deep link
|
||||
console.log("handle callback on paste:", pastedText);
|
||||
handleUrl(pastedText);
|
||||
}
|
||||
};
|
||||
|
||||
// Function to check if the pasted URL is valid for our deep link scheme
|
||||
const isValidCallbackUrl = (url: string) => {
|
||||
return url && url.startsWith("coco://oauth_callback");
|
||||
};
|
||||
|
||||
// Adding event listener for paste events
|
||||
document.addEventListener("paste", handlePaste);
|
||||
|
||||
getCurrentDeepLinkUrls()
|
||||
.then((urls) => {
|
||||
console.log("URLs:", urls);
|
||||
if (urls && urls.length > 0) {
|
||||
if (isValidCallbackUrl(urls[0].trim())) {
|
||||
handleUrl(urls[0]);
|
||||
}
|
||||
const unlistenOAuth = platformAdapter.listenEvent(
|
||||
"oauth_success",
|
||||
(event) => {
|
||||
const { serverId } = event.payload;
|
||||
if (serverId) {
|
||||
refreshClick(serverId, () => {
|
||||
setLoading(false);
|
||||
});
|
||||
addError(language === "zh" ? "登录成功" : "Login Success", "info");
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Failed to get initial URLs:", err);
|
||||
addError("Failed to get initial URLs: " + err);
|
||||
});
|
||||
|
||||
const unlisten = onOpenUrl((urls) => handleUrl(urls[0]));
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
unlisten.then((fn) => fn());
|
||||
document.removeEventListener("paste", handlePaste);
|
||||
unlistenOAuth.then((fn) => fn());
|
||||
};
|
||||
}, [ssoRequestID]);
|
||||
}, [refreshClick]);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(false);
|
||||
@@ -214,7 +136,9 @@ const ServiceAuth = memo(
|
||||
<button
|
||||
className="text-xs text-[#0096FB] dark:text-blue-400 block"
|
||||
onClick={() =>
|
||||
OpenURLWithBrowser(cloudSelectService?.provider?.privacy_policy)
|
||||
OpenURLWithBrowser(
|
||||
cloudSelectService?.provider?.privacy_policy
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("cloud.privacyPolicy")}
|
||||
@@ -258,9 +182,9 @@ const LoadingState: FC<LoadingStateProps> = memo((props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex items-center space-x-2 mb-3">
|
||||
<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-md hover:bg-red-600 transition-colors"
|
||||
onClick={onCancel}
|
||||
>
|
||||
{t("cloud.cancel")}
|
||||
|
||||
@@ -53,7 +53,7 @@ export const Sidebar = forwardRef<{ refreshData: () => void }, SidebarProps>(
|
||||
<img
|
||||
src={item?.provider?.icon || cocoLogoImg}
|
||||
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) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.src = cocoLogoImg;
|
||||
|
||||
@@ -24,13 +24,13 @@ export function UserProfile({ server, userInfo, onLogout }: UserProfileProps) {
|
||||
<img
|
||||
src={userInfo?.avatar}
|
||||
alt=""
|
||||
className="w-6 h-6"
|
||||
className="w-6 h-6 dark:drop-shadow-[0_0_6px_rgb(255,255,255)]"
|
||||
onError={() => {
|
||||
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 className="flex-1">
|
||||
|
||||
@@ -44,7 +44,7 @@ function CommonIcon({
|
||||
// Handle regular icon types
|
||||
const renderIconByType = (renderType: string) => {
|
||||
if (isNil(isAbsolute)) return null;
|
||||
|
||||
|
||||
switch (renderType) {
|
||||
case "special_icon": {
|
||||
if (item.id === "Calculator") {
|
||||
|
||||
@@ -8,7 +8,7 @@ interface FontIconProps {
|
||||
|
||||
const FontIcon: FC<FontIconProps> = ({ name, className, style, ...rest }) => {
|
||||
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}`} />
|
||||
</svg>
|
||||
);
|
||||
|
||||
@@ -24,7 +24,12 @@ function ThemedIcon({ component: Component, className = "" }: ThemedIconProps) {
|
||||
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;
|
||||
|
||||
@@ -49,7 +49,13 @@ function UniversalIcon({
|
||||
|
||||
// Render image type icon
|
||||
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 ? (
|
||||
<IconWrapper className={className} onClick={onClick}>
|
||||
{img}
|
||||
@@ -63,7 +69,7 @@ function UniversalIcon({
|
||||
const renderAppIcon = (src: string) => {
|
||||
const img = (
|
||||
<img
|
||||
className={className}
|
||||
className={`dark:drop-shadow-[0_0_6px_rgb(255,255,255)] ${className}`}
|
||||
src={platformAdapter.convertFileSrc(src)}
|
||||
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;
|
||||
@@ -5,13 +5,10 @@ import clsx from "clsx";
|
||||
|
||||
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 { useAppStore } from "@/stores/appStore";
|
||||
import { useSearchStore } from "@/stores/searchStore";
|
||||
import { useUpdateStore } from "@/stores/updateStore";
|
||||
import VisibleKey from "../VisibleKey";
|
||||
import { useShortcutsStore } from "@/stores/shortcutsStore";
|
||||
import { formatKey } from "@/utils/keyboardUtils";
|
||||
import source_default_img from "@/assets/images/source_default.png";
|
||||
@@ -19,7 +16,7 @@ import source_default_dark_img from "@/assets/images/source_default_dark.png";
|
||||
import { useThemeStore } from "@/stores/themeStore";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
import FontIcon from "../Icons/FontIcon";
|
||||
import { useTogglePin } from "@/hooks/useTogglePin";
|
||||
import TogglePin from "../TogglePin";
|
||||
|
||||
interface FooterProps {
|
||||
setIsPinnedWeb?: (value: boolean) => void;
|
||||
@@ -40,13 +37,9 @@ export default function Footer({ setIsPinnedWeb }: FooterProps) {
|
||||
|
||||
const { isTauri } = useAppStore();
|
||||
|
||||
const { isPinned, togglePin } = useTogglePin({
|
||||
onPinChange: setIsPinnedWeb,
|
||||
});
|
||||
|
||||
const { setVisible, updateInfo, skipVersions } = useUpdateStore();
|
||||
|
||||
const { fixedWindow, modifierKey } = useShortcutsStore();
|
||||
const { modifierKey } = useShortcutsStore();
|
||||
|
||||
const openSetting = useCallback(() => {
|
||||
return platformAdapter.emitEvent("open_settings", "");
|
||||
@@ -76,7 +69,10 @@ export default function Footer({ setIsPinnedWeb }: FooterProps) {
|
||||
if (visibleExtensionDetail && selectedExtension) {
|
||||
return (
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
@@ -127,17 +123,12 @@ export default function Footer({ setIsPinnedWeb }: FooterProps) {
|
||||
<div className="flex items-center space-x-2">
|
||||
{renderLeft()}
|
||||
|
||||
<button
|
||||
onClick={togglePin}
|
||||
<TogglePin
|
||||
className={clsx({
|
||||
"text-blue-500": isPinned,
|
||||
"pl-2": hasUpdate,
|
||||
})}
|
||||
>
|
||||
<VisibleKey shortcut={fixedWindow} onKeyPress={togglePin}>
|
||||
{isPinned ? <PinIcon /> : <PinOffIcon />}
|
||||
</VisibleKey>
|
||||
</button>
|
||||
setIsPinnedWeb={setIsPinnedWeb}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -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 { useShortcutsStore } from "@/stores/shortcutsStore";
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
import { KeyType } from "ahooks/lib/useKeyPress";
|
||||
|
||||
const keyTriggerMap = new Map<KeyType, number>();
|
||||
|
||||
interface VisibleKeyProps extends HTMLAttributes<HTMLDivElement> {
|
||||
shortcut: string;
|
||||
@@ -60,8 +63,16 @@ const VisibleKey: FC<VisibleKeyProps> = (props) => {
|
||||
setVisibleShortcut(isChildInPopover && modifierKeyPressed);
|
||||
}, [openPopover, modifierKeyPressed]);
|
||||
|
||||
useKeyPress(`${modifierKey}.${shortcut}`, (event) => {
|
||||
if (!visibleShortcut) return;
|
||||
useKeyPress(`${modifierKey}.${shortcut}`, (event, key) => {
|
||||
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.preventDefault();
|
||||
@@ -82,6 +93,10 @@ const VisibleKey: FC<VisibleKeyProps> = (props) => {
|
||||
return "↩︎";
|
||||
}
|
||||
|
||||
if (shortcut === "backspace") {
|
||||
return "⌫";
|
||||
}
|
||||
|
||||
return shortcut;
|
||||
};
|
||||
|
||||
|
||||
@@ -107,7 +107,7 @@ const AskAi: FC<AskAiProps> = (props) => {
|
||||
unlisten.current = await platformAdapter.listenEvent(
|
||||
"quick-ai-access-client-id",
|
||||
({ payload }) => {
|
||||
console.log("ask_ai", JSON.parse(payload));
|
||||
// console.log("ask_ai", JSON.parse(payload));
|
||||
|
||||
const chunkData = JSON.parse(payload);
|
||||
|
||||
|
||||
@@ -7,6 +7,9 @@ import platformAdapter from "@/utils/platformAdapter";
|
||||
import { Get } from "@/api/axiosRequest";
|
||||
import type { Assistant } from "@/types/chat";
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
import { canNavigateBack, navigateBack } from "@/utils";
|
||||
import { useKeyPress } from "ahooks";
|
||||
import { useShortcutsStore } from "@/stores/shortcutsStore";
|
||||
|
||||
interface AssistantManagerProps {
|
||||
isChatMode: boolean;
|
||||
@@ -33,9 +36,9 @@ export function useAssistantManager({
|
||||
setVisibleExtensionStore,
|
||||
setSearchValue,
|
||||
visibleExtensionDetail,
|
||||
setVisibleExtensionDetail,
|
||||
sourceData,
|
||||
setSourceData,
|
||||
setVisibleExtensionDetail,
|
||||
} = useSearchStore();
|
||||
|
||||
const { quickAiAccessAssistant, disabledExtensions } = useExtensionsStore();
|
||||
@@ -47,6 +50,7 @@ export function useAssistantManager({
|
||||
}, [quickAiAccessAssistant, selectedAssistant]);
|
||||
|
||||
const [assistantDetail, setAssistantDetail] = useState<any>({});
|
||||
const { modifierKey } = useShortcutsStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (goAskAi) return;
|
||||
@@ -76,7 +80,7 @@ export function useAssistantManager({
|
||||
}, [askAI?.id, askAI?.querySource?.id, disabledExtensions]);
|
||||
|
||||
const handleAskAi = useCallback(() => {
|
||||
if (!isTauri) return;
|
||||
if (!isTauri || canNavigateBack()) return;
|
||||
|
||||
if (disabledExtensions.includes("QuickAIAccess")) return;
|
||||
|
||||
@@ -99,39 +103,14 @@ export function useAssistantManager({
|
||||
const handleKeyDownAutoResizeTextarea = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
const { key, shiftKey, currentTarget } = e;
|
||||
const { value } = currentTarget;
|
||||
const { value, selectionStart, selectionEnd } = currentTarget;
|
||||
|
||||
if (key === "Backspace" && value === "") {
|
||||
if (goAskAi) {
|
||||
return setGoAskAi(false);
|
||||
}
|
||||
const cursorStart = selectionStart === 0 && selectionEnd === 0;
|
||||
|
||||
if (visibleExtensionDetail) {
|
||||
return setVisibleExtensionDetail(false);
|
||||
}
|
||||
|
||||
if (visibleExtensionStore) {
|
||||
return setVisibleExtensionStore(false);
|
||||
}
|
||||
|
||||
if (sourceData) {
|
||||
return setSourceData(void 0);
|
||||
}
|
||||
}
|
||||
|
||||
if (key === "Tab" && !isChatMode && isTauri) {
|
||||
if (key === "Backspace" && (value === "" || cursorStart)) {
|
||||
e.preventDefault();
|
||||
|
||||
if (visibleExtensionStore) return;
|
||||
|
||||
if (selectedSearchContent?.id === "Extension Store") {
|
||||
changeInput("");
|
||||
setSearchValue("");
|
||||
return setVisibleExtensionStore(true);
|
||||
}
|
||||
|
||||
assistant_get();
|
||||
return handleAskAi();
|
||||
return navigateBack();
|
||||
}
|
||||
|
||||
if (key === "Enter" && !shiftKey) {
|
||||
@@ -147,6 +126,17 @@ export function useAssistantManager({
|
||||
|
||||
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,
|
||||
@@ -161,6 +151,69 @@ 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) {
|
||||
const { setViewExtensionOpened } = useSearchStore.getState();
|
||||
const viewData = onOpened.Extension.ty.View;
|
||||
const extensionPermission = onOpened.Extension.permission;
|
||||
|
||||
clearSearchValue();
|
||||
return setViewExtensionOpened([
|
||||
viewData.page,
|
||||
extensionPermission,
|
||||
viewData.ui,
|
||||
selectedSearchContent as any,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if (type === "AI Assistant") {
|
||||
assistant_get();
|
||||
return handleAskAi();
|
||||
}
|
||||
|
||||
setSourceData(selectedSearchContent);
|
||||
});
|
||||
|
||||
useKeyPress(`${modifierKey}.enter`, () => {
|
||||
assistant_get();
|
||||
return handleAskAi();
|
||||
});
|
||||
|
||||
return {
|
||||
askAI,
|
||||
askAIRef,
|
||||
|
||||
@@ -111,7 +111,7 @@ const ContextMenu = ({ formatUrl }: ContextMenuProps) => {
|
||||
return [];
|
||||
}
|
||||
|
||||
const { id, url, category, type, payload } = selectedSearchContent;
|
||||
const { id, url, category, type, payload, source } = selectedSearchContent;
|
||||
const { query, result } = payload ?? {};
|
||||
|
||||
if (category === "AI Overview") {
|
||||
@@ -177,6 +177,18 @@ const ContextMenu = ({ formatUrl }: ContextMenuProps) => {
|
||||
copyToClipboard(`${query.value} = ${result.value}`);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: t("search.contextMenu.openFileLocation"),
|
||||
icon: <SquareArrowOutUpRight />,
|
||||
keys: isMac ? ["⌘", "↩︎"] : ["Ctrl", "Enter"],
|
||||
shortcut: isMac ? "meta.enter" : "ctrl.enter",
|
||||
hide: source?.id !== "File Search",
|
||||
clickEvent: async () => {
|
||||
await platformAdapter.revealItemInDir(url);
|
||||
|
||||
platformAdapter.hideWindow();
|
||||
},
|
||||
},
|
||||
];
|
||||
}, [selectedSearchContent, selectedExtension]);
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import React, { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { useInfiniteScroll } from "ahooks";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Data } from "ahooks/lib/useInfiniteScroll/types";
|
||||
import { nanoid } from "nanoid";
|
||||
import { isNil } from "lodash-es";
|
||||
|
||||
import { useSearchStore } from "@/stores/searchStore";
|
||||
import { SearchHeader } from "./SearchHeader";
|
||||
@@ -11,9 +14,7 @@ import { Get } from "@/api/axiosRequest";
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
import { useConnectStore } from "@/stores/connectStore";
|
||||
import SearchEmpty from "../Common/SearchEmpty";
|
||||
import { Data } from "ahooks/lib/useInfiniteScroll/types";
|
||||
import { nanoid } from "nanoid";
|
||||
import { isNil } from "lodash-es";
|
||||
import Scrollbar from "@/components/Common/Scrollbar";
|
||||
|
||||
interface DocumentListProps {
|
||||
onSelectDocument: (id: string) => void;
|
||||
@@ -297,8 +298,8 @@ export const DocumentList: React.FC<DocumentListProps> = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex-1 overflow-auto custom-scrollbar pr-0.5"
|
||||
<Scrollbar
|
||||
className="flex-1 overflow-auto pr-0.5"
|
||||
ref={containerRef}
|
||||
>
|
||||
{data?.list && data.list.length > 0 && (
|
||||
@@ -334,7 +335,7 @@ export const DocumentList: React.FC<DocumentListProps> = ({
|
||||
<SearchEmpty />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Scrollbar>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -16,6 +16,7 @@ import { useKeyboardNavigation } from "@/hooks/useKeyboardNavigation";
|
||||
import { SearchSource } from "./SearchSource";
|
||||
import DropdownListItem from "./DropdownListItem";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
import Scrollbar from "@/components/Common/Scrollbar";
|
||||
|
||||
type ISearchData = Record<string, QueryHits[]>;
|
||||
|
||||
@@ -145,13 +146,14 @@ function DropdownList({
|
||||
handleItemAction,
|
||||
isChatMode,
|
||||
formatUrl,
|
||||
searchData,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
<Scrollbar
|
||||
ref={containerRef}
|
||||
data-tauri-drag-region
|
||||
className="h-full w-full p-2 flex flex-col overflow-y-auto custom-scrollbar focus:outline-none"
|
||||
className="h-full w-full p-2 flex flex-col overflow-y-auto focus:outline-none"
|
||||
tabIndex={0}
|
||||
role="listbox"
|
||||
aria-label={t("search.header.results")}
|
||||
@@ -188,7 +190,7 @@ function DropdownList({
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Scrollbar>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user