1 Commits

Author SHA1 Message Date
ayang
e7f8f7ef6e chore: add macos config for tauri 2025-07-28 16:31:54 +08:00
199 changed files with 5509 additions and 13194 deletions

2
.env
View File

@@ -1,3 +1,5 @@
COCO_SERVER_URL=http://localhost:9000 #https://coco.infini.cloud #http://localhost:9000
COCO_WEBSOCKET_URL=ws://localhost:9000/ws #wss://coco.infini.cloud/ws #ws://localhost:9000/ws
#TAURI_DEV_HOST=0.0.0.0

View File

@@ -1,34 +0,0 @@
name: Frontend Code Check
on:
pull_request:
# Only run it when Frontend code changes
paths:
- 'src/**'
jobs:
check:
strategy:
matrix:
platform: [ubuntu-latest, windows-latest, macos-latest]
runs-on: ${{ matrix.platform }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
# No need to pass the version arg as it is specified by "packageManager" in package.json
- name: Install pnpm
uses: pnpm/action-setup@v4
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build frontend
run: pnpm build

View File

@@ -104,17 +104,7 @@ 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 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
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf xdg-utils
- name: Add Rust build target
working-directory: src-tauri

View File

@@ -30,15 +30,7 @@ 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 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
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf xdg-utils
- name: Add pizza engine as a dependency
working-directory: src-tauri

View File

@@ -83,6 +83,5 @@
"i18n-ally.keystyle": "nested",
"editor.tabSize": 2,
"editor.insertSpaces": true,
"editor.detectIndentation": false,
"i18n-ally.displayLanguage": "zh"
"editor.detectIndentation": false
}

View File

@@ -64,9 +64,9 @@ At Coco AI, we aim to streamline workplace collaboration by centralizing access
### Prerequisites
- [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)
- Node.js >= 18.12
- Rust (latest stable)
- pnpm (package manager)
### Development Setup

View File

@@ -1,56 +0,0 @@
1. Send a PR that updates the release notes "docs/content.en/docs/release-notes/_index.md", and
merge it into `main`.
2. Run release command (by @medcl)
Make sure you are on the latest main branch, then run `pnpm release`:
> NOTE: A tag is needed to trigger the [release CI][release_ci].
```sh
➜ coco-app git:(main) ✗ pnpm release
🚀 Let's release coco (currently at a.b.c)
Changelog:
* xxx
* xxx
✔ Select increment (next version):
Changeset:
M package.json
M src-tauri/Cargo.lock
M src-tauri/Cargo.toml
✔ Commit (vX.Y.Z)? Yes
✔ Tag (vX.Y.Z)? Yes
✔ Push? Yes
🏁 Done
```
3. Build & Move Release Package
1. [Build][ci] the package for this release
2. @luohoufu moves the package to the stable folder.
![release](./docs/static/img/release.png)
4. Update the [roadmap](https://coco.rs/en/roadmap) (if needed)
> You should update both English and Chinese JSON files
>
> * English: https://github.com/infinilabs/coco-website/blob/main/i18n/locales/en.json
> * Chinese: https://github.com/infinilabs/coco-website/blob/main/i18n/locales/zh.json
1. Add a new [section][roadmap_new] for the new release
2. Adjust the entries under [In Progress][in_prog] and [Up Next][up_next] accordingly
* Completed items should be removed from "In Progress"
* Some items should be moved from "Up Next" to "In Progress"
[release_ci]: https://github.com/infinilabs/coco-app/blob/main/.github/workflows/release.yml
[ci]: https://github.com/infinilabs/ci/actions/workflows/coco-app.yml
[roadmap_new]: https://github.com/infinilabs/coco-website/blob/5ae30bdfad0724bf27b4da8621b86be1dbe7bb8b/i18n/locales/en.json#L206-L218
[in_prog]: https://github.com/infinilabs/coco-website/blob/5ae30bdfad0724bf27b4da8621b86be1dbe7bb8b/i18n/locales/en.json#L121
[up_next]: https://github.com/infinilabs/coco-website/blob/5ae30bdfad0724bf27b4da8621b86be1dbe7bb8b/i18n/locales/en.json#L156

View File

@@ -13,12 +13,6 @@ 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

View File

@@ -8,64 +8,19 @@ title: "Release Notes"
Information about release notes of Coco App is provided here.
## Latest (In development)
### ❌ Breaking changes
### 🚀 Features
### 🐛 Bug fix
### ✈️ Improvements
## 0.8.0 (2025-09-28)
### ❌ Breaking changes
- chore: update request accesstoken api #866
### 🚀 Features
- feat: enhance ui for skipped version #834
- feat: support installing local extensions #749
- feat: support sending files in chat messages #764
- feat: sub extension can set 'platforms' now #847
- feat: add extension uninstall option in settings #855
- feat: impl extension settings 'hide_before_open' #862
- feat: index both en/zh_CN app names and show app name in chosen language #875
- feat: support context menu in debug mode #882
- feat: file search for Linux/GNOME #884
- feat: file search for Linux/KDE #886
- feat: extension Window Management for macOS #892
- feat: new extension type View #894
- feat: support opening file in its containing folder #900
### 🐛 Bug fix
- fix: fix issue with update check failure #833
- fix: web component login state #857
- fix: shortcut key not opening extension store #877
- fix: set up hotkey on main thread or Windows will complain #879
- fix: resolve deeplink login issue #881
- fix: use kill_on_drop() to avoid zombie proc in error case #887
- fix: settings window rendering/loading issue 889
- fix: ensure search paths are indexed #896
- fix: bump applications-rs to fix empty app name issue #898
### ✈️ Improvements
- refactor: calling service related interfaces #831
- refactor: split query_coco_fusion() #836
- chore: web component loading font icon #838
- chore: delete unused code files and dependencies #841
- chore: ignore tauri::AppHandle's generic argument R #845
- refactor: check Extension/plugin.json from all sources #846
- refactor: pinning window won't set CanJoinAllSpaces on macOS #854
- build: web component build error #858
- refactor: coordinate third-party extension operations using lock #867
- refactor: index iOS apps and macOS apps that store icon in Assets.car #872
- refactor: accept both '-' and '\_' as locale str separator #876
- refactor: relax the file search conditions on macOS #883
- refactor: ensure Coco won't take focus #891
- chore: skip login check for web widget #895
- chore: convertFileSrc() "link[href]" and "img[src]" #901
## 0.7.1 (2025-07-27)
### ❌ Breaking changes

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

View File

@@ -1,7 +1,7 @@
{
"name": "coco",
"private": true,
"version": "0.8.0",
"version": "0.7.1",
"type": "module",
"scripts": {
"dev": "vite",
@@ -26,11 +26,12 @@
"@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.5.0",
"@tauri-apps/plugin-opener": "^2.2.7",
"@tauri-apps/plugin-os": "^2.2.1",
"@tauri-apps/plugin-process": "^2.2.1",
"@tauri-apps/plugin-shell": "^2.2.1",
"@tauri-apps/plugin-updater": "github:infinilabs/tauri-plugin-updater#v2",
"@tauri-apps/plugin-websocket": "~2.3.0",
"@tauri-apps/plugin-window": "2.0.0-alpha.1",
"@wavesurfer/react": "^1.0.11",
"ahooks": "^3.8.4",
@@ -93,4 +94,4 @@
"vite": "^5.4.19"
},
"packageManager": "pnpm@10.11.0+sha512.6540583f41cc5f628eb3d9773ecee802f4f9ef9923cc45b69890fb47991d4b092964694ec3a4f738a420c918a333062c8b925d312f42e4f0c263eb603551f977"
}
}

73
pnpm-lock.yaml generated
View File

@@ -33,8 +33,8 @@ importers:
specifier: ~2.4.0
version: 2.4.0
'@tauri-apps/plugin-opener':
specifier: ^2.5.0
version: 2.5.0
specifier: ^2.2.7
version: 2.2.7
'@tauri-apps/plugin-os':
specifier: ^2.2.1
version: 2.2.1
@@ -47,6 +47,9 @@ importers:
'@tauri-apps/plugin-updater':
specifier: github:infinilabs/tauri-plugin-updater#v2
version: https://codeload.github.com/infinilabs/tauri-plugin-updater/tar.gz/358e689c65e9943b53eff50bcb9dfd5b1cfc4072
'@tauri-apps/plugin-websocket':
specifier: ~2.3.0
version: 2.3.0
'@tauri-apps/plugin-window':
specifier: 2.0.0-alpha.1
version: 2.0.0-alpha.1
@@ -790,9 +793,6 @@ packages:
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
engines: {node: '>=12'}
'@jridgewell/gen-mapping@0.3.13':
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
'@jridgewell/gen-mapping@0.3.8':
resolution: {integrity: sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==}
engines: {node: '>=6.0.0'}
@@ -805,21 +805,15 @@ packages:
resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==}
engines: {node: '>=6.0.0'}
'@jridgewell/source-map@0.3.11':
resolution: {integrity: sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==}
'@jridgewell/source-map@0.3.6':
resolution: {integrity: sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==}
'@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==}
@@ -1162,9 +1156,6 @@ 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'}
@@ -1254,8 +1245,8 @@ packages:
'@tauri-apps/plugin-log@2.4.0':
resolution: {integrity: sha512-j7yrDtLNmayCBOO2esl3aZv9jSXy2an8MDLry3Ys9ZXerwUg35n1Y2uD8HoCR+8Ng/EUgx215+qOUfJasjYrHw==}
'@tauri-apps/plugin-opener@2.5.0':
resolution: {integrity: sha512-B0LShOYae4CZjN8leiNDbnfjSrTwoZakqKaWpfoH6nXiJwt6Rgj6RnVIffG3DoJiKsffRhMkjmBV9VeilSb4TA==}
'@tauri-apps/plugin-opener@2.2.7':
resolution: {integrity: sha512-uduEyvOdjpPOEeDRrhwlCspG/f9EQalHumWBtLBnp3fRp++fKGLqDOyUhSIn7PzX45b/rKep//ZQSAQoIxobLA==}
'@tauri-apps/plugin-os@2.2.1':
resolution: {integrity: sha512-cNYpNri2CCc6BaNeB6G/mOtLvg8dFyFQyCUdf2y0K8PIAKGEWdEcu8DECkydU2B+oj4OJihDPD2de5K6cbVl9A==}
@@ -1270,6 +1261,9 @@ packages:
resolution: {tarball: https://codeload.github.com/infinilabs/tauri-plugin-updater/tar.gz/358e689c65e9943b53eff50bcb9dfd5b1cfc4072}
version: 2.7.1
'@tauri-apps/plugin-websocket@2.3.0':
resolution: {integrity: sha512-eAwRGe3tnqDeQYE0wq4g1PUKbam9tYvlC4uP/au12Y/z7MP4lrS4ylv+aoZ5Ly+hTlBdi7hDkhHomwF/UeBesA==}
'@tauri-apps/plugin-window@2.0.0-alpha.1':
resolution: {integrity: sha512-dFOAgal/3Txz3SQ+LNQq0AK1EPC+acdaFlwPVB/6KXUZYmaFleIlzgxDVoJCQ+/xOhxvYrdQaFLefh0I/Kldbg==}
@@ -1476,11 +1470,6 @@ 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'}
@@ -4247,12 +4236,6 @@ 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
@@ -4263,28 +4246,19 @@ snapshots:
'@jridgewell/set-array@1.2.1': {}
'@jridgewell/source-map@0.3.11':
'@jridgewell/source-map@0.3.6':
dependencies:
'@jridgewell/gen-mapping': 0.3.13
'@jridgewell/trace-mapping': 0.3.31
'@jridgewell/gen-mapping': 0.3.8
'@jridgewell/trace-mapping': 0.3.25
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
@@ -4578,8 +4552,6 @@ snapshots:
'@tauri-apps/api@2.5.0': {}
'@tauri-apps/api@2.8.0': {}
'@tauri-apps/cli-darwin-arm64@2.5.0':
optional: true
@@ -4651,9 +4623,9 @@ snapshots:
dependencies:
'@tauri-apps/api': 2.5.0
'@tauri-apps/plugin-opener@2.5.0':
'@tauri-apps/plugin-opener@2.2.7':
dependencies:
'@tauri-apps/api': 2.8.0
'@tauri-apps/api': 2.5.0
'@tauri-apps/plugin-os@2.2.1':
dependencies:
@@ -4671,6 +4643,10 @@ snapshots:
dependencies:
'@tauri-apps/api': 2.5.0
'@tauri-apps/plugin-websocket@2.3.0':
dependencies:
'@tauri-apps/api': 2.5.0
'@tauri-apps/plugin-window@2.0.0-alpha.1':
dependencies:
'@tauri-apps/api': 2.0.0-alpha.6
@@ -4910,9 +4886,6 @@ snapshots:
acorn@8.14.1: {}
acorn@8.15.0:
optional: true
agent-base@7.1.3: {}
ahooks@3.8.4(react@18.3.1):
@@ -7269,8 +7242,8 @@ snapshots:
terser@5.40.0:
dependencies:
'@jridgewell/source-map': 0.3.11
acorn: 8.15.0
'@jridgewell/source-map': 0.3.6
acorn: 8.14.1
commander: 2.20.3
source-map-support: 0.5.21
optional: true

2917
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package]
name = "coco"
version = "0.8.0"
version = "0.7.1"
description = "Search, connect, collaborate all in one place."
authors = ["INFINI Labs"]
edition = "2024"
@@ -15,7 +15,6 @@ crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = ["default"] }
cfg-if = "1.0.1"
[features]
default = ["desktop"]
@@ -52,6 +51,7 @@ serde = { version = "1", features = ["derive"] }
# see: https://docs.rs/serde_json/latest/serde_json/struct.Number.html#method.from_u128
serde_json = { version = "1", features = ["arbitrary_precision", "preserve_order"] }
tauri-plugin-http = "2"
tauri-plugin-websocket = "2"
tauri-plugin-deep-link = "2.0.0"
tauri-plugin-store = "2.2.0"
tauri-plugin-os = "2"
@@ -62,7 +62,7 @@ tauri-plugin-drag = "2"
tauri-plugin-macos-permissions = "2"
tauri-plugin-fs-pro = "2"
tauri-plugin-screenshots = "2"
applications = { git = "https://github.com/infinilabs/applications-rs", rev = "b5fac4034a40d42e72f727f1aa1cc1f19fe86653" }
applications = { git = "https://github.com/infinilabs/applications-rs", rev = "31b0c030a0f3bc82275fe12debe526153978671d" }
tokio-native-tls = "0.3" # For wss connections
tokio = { version = "1", features = ["full"] }
tokio-tungstenite = { version = "0.20", features = ["native-tls"] }
@@ -104,42 +104,15 @@ zip = "4.0.0"
url = "2.5.2"
camino = "1.1.10"
tokio-stream = { version = "0.1.17", features = ["io-util"] }
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"
sysinfo = "0.35.2"
[target."cfg(target_os = \"macos\")".dependencies]
tauri-nspanel = { git = "https://github.com/ahkohd/tauri-nspanel", branch = "v2" }
objc2-app-kit = { version = "0.3.1", features = ["NSWindow"] }
objc2 = "0.6.2"
objc2-core-foundation = {version = "0.3.1", features = ["CFString", "CFCGTypes", "CFArray"] }
objc2-application-services = { version = "0.3.1", features = ["HIServices"] }
objc2-core-graphics = { version = "=0.3.1", features = ["CGEvent"] }
[target."cfg(target_os = \"linux\")".dependencies]
gio = "0.21.2"
glib = "0.21.2"
tracker-rs = "0.7"
which = "8.0.0"
configparser = "3.1.0"
cocoa = "0.24"
[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.
@@ -160,8 +133,4 @@ semver = { version = "1", features = ["serde"] }
[target."cfg(target_os = \"windows\")".dependencies]
enigo="0.3"
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"
windows = { version = "0.61.3", features = ["Win32_Foundation", "Win32_System_Com", "Win32_System_Ole", "Win32_System_Search", "Win32_UI_Shell_PropertiesSystem", "Win32_Data"] }

View File

@@ -11,32 +11,4 @@ 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.
}
}
}

View File

@@ -37,6 +37,9 @@
"http:allow-fetch-cancel",
"http:allow-fetch-read-body",
"http:allow-fetch-send",
"websocket:default",
"websocket:allow-connect",
"websocket:allow-send",
"autostart:allow-enable",
"autostart:allow-disable",
"autostart:allow-is-enabled",
@@ -69,7 +72,6 @@
"updater:default",
"windows-version:default",
"log:default",
"opener:default",
"core:window:allow-unminimize"
"opener:default"
]
}

View File

@@ -1,5 +1,5 @@
use crate::common::assistant::ChatRequestMessage;
use crate::common::http::convert_query_params_to_strings;
use crate::common::http::{GetResponse, convert_query_params_to_strings};
use crate::common::register::SearchSourceRegistry;
use crate::server::http_client::HttpClient;
use crate::{common, server::servers::COCO_SERVERS};
@@ -9,12 +9,12 @@ use futures_util::TryStreamExt;
use http::Method;
use serde_json::Value;
use std::collections::HashMap;
use tauri::{AppHandle, Emitter, Manager};
use tauri::{AppHandle, Emitter, Manager, Runtime};
use tokio::io::AsyncBufReadExt;
#[tauri::command]
pub async fn chat_history(
_app_handle: AppHandle,
pub async fn chat_history<R: Runtime>(
_app_handle: AppHandle<R>,
server_id: String,
from: u32,
size: u32,
@@ -43,8 +43,8 @@ pub async fn chat_history(
}
#[tauri::command]
pub async fn session_chat_history(
_app_handle: AppHandle,
pub async fn session_chat_history<R: Runtime>(
_app_handle: AppHandle<R>,
server_id: String,
session_id: String,
from: u32,
@@ -66,8 +66,8 @@ pub async fn session_chat_history(
}
#[tauri::command]
pub async fn open_session_chat(
_app_handle: AppHandle,
pub async fn open_session_chat<R: Runtime>(
_app_handle: AppHandle<R>,
server_id: String,
session_id: String,
) -> Result<String, String> {
@@ -81,8 +81,8 @@ pub async fn open_session_chat(
}
#[tauri::command]
pub async fn close_session_chat(
_app_handle: AppHandle,
pub async fn close_session_chat<R: Runtime>(
_app_handle: AppHandle<R>,
server_id: String,
session_id: String,
) -> Result<String, String> {
@@ -95,8 +95,8 @@ pub async fn close_session_chat(
common::http::get_response_body_text(response).await
}
#[tauri::command]
pub async fn cancel_session_chat(
_app_handle: AppHandle,
pub async fn cancel_session_chat<R: Runtime>(
_app_handle: AppHandle<R>,
server_id: String,
session_id: String,
query_params: Option<HashMap<String, Value>>,
@@ -112,37 +112,72 @@ pub async fn cancel_session_chat(
}
#[tauri::command]
pub async fn chat_create(
app_handle: AppHandle,
pub async fn new_chat<R: Runtime>(
_app_handle: AppHandle<R>,
server_id: String,
message: Option<String>,
attachments: Option<Vec<String>>,
websocket_id: String,
message: String,
query_params: Option<HashMap<String, Value>>,
client_id: String,
) -> Result<(), String> {
println!("chat_create message: {:?}", message);
println!("chat_create attachments: {:?}", attachments);
let message_empty = message.as_ref().map_or(true, |m| m.is_empty());
let attachments_empty = attachments.as_ref().map_or(true, |a| a.is_empty());
if message_empty && attachments_empty {
return Err("Message and attachments are empty".to_string());
}
let body = {
let request_message: ChatRequestMessage = ChatRequestMessage {
message,
attachments,
) -> Result<GetResponse, String> {
let body = if !message.is_empty() {
let message = ChatRequestMessage {
message: Some(message),
};
println!("chat_create body: {:?}", request_message);
Some(
serde_json::to_string(&request_message)
serde_json::to_string(&message)
.map_err(|e| format!("Failed to serialize message: {}", e))?
.into(),
)
} else {
None
};
let mut headers = HashMap::new();
headers.insert("WEBSOCKET-SESSION-ID".to_string(), websocket_id.into());
let response = HttpClient::advanced_post(
&server_id,
"/chat/_new",
Some(headers),
convert_query_params_to_strings(query_params),
body,
)
.await
.map_err(|e| format!("Error sending message: {}", e))?;
let body_text = common::http::get_response_body_text(response).await?;
log::debug!("New chat response: {}", &body_text);
let chat_response: GetResponse = serde_json::from_str(&body_text)
.map_err(|e| format!("Failed to parse response JSON: {}", e))?;
if chat_response.result != "created" {
return Err(format!("Unexpected result: {}", chat_response.result));
}
Ok(chat_response)
}
#[tauri::command]
pub async fn chat_create<R: Runtime>(
app_handle: AppHandle<R>,
server_id: String,
message: String,
query_params: Option<HashMap<String, Value>>,
client_id: String,
) -> Result<(), String> {
let body = if !message.is_empty() {
let message = ChatRequestMessage {
message: Some(message),
};
Some(
serde_json::to_string(&message)
.map_err(|e| format!("Failed to serialize message: {}", e))?
.into(),
)
} else {
None
};
let response = HttpClient::advanced_post(
@@ -178,6 +213,8 @@ pub async fn chat_create(
if let Err(err) = app_handle.emit(&client_id, line) {
log::error!("Emit failed: {:?}", err);
print!("Error sending message: {:?}", err);
let _ = app_handle.emit("chat-create-error", format!("Emit failed: {:?}", err));
}
}
@@ -186,38 +223,56 @@ pub async fn chat_create(
}
#[tauri::command]
pub async fn chat_chat(
app_handle: AppHandle,
pub async fn send_message<R: Runtime>(
_app_handle: AppHandle<R>,
server_id: String,
websocket_id: String,
session_id: String,
message: String,
query_params: Option<HashMap<String, Value>>, //search,deep_thinking
) -> Result<String, String> {
let path = format!("/chat/{}/_send", session_id);
let msg = ChatRequestMessage {
message: Some(message),
};
let mut headers = HashMap::new();
headers.insert("WEBSOCKET-SESSION-ID".to_string(), websocket_id.into());
let body = reqwest::Body::from(serde_json::to_string(&msg).unwrap());
let response = HttpClient::advanced_post(
&server_id,
path.as_str(),
Some(headers),
convert_query_params_to_strings(query_params),
Some(body),
)
.await
.map_err(|e| format!("Error cancel session: {}", e))?;
common::http::get_response_body_text(response).await
}
#[tauri::command]
pub async fn chat_chat<R: Runtime>(
app_handle: AppHandle<R>,
server_id: String,
session_id: String,
message: Option<String>,
attachments: Option<Vec<String>>,
message: String,
query_params: Option<HashMap<String, Value>>, //search,deep_thinking
client_id: String,
) -> Result<(), String> {
println!("chat_chat message: {:?}", message);
println!("chat_chat attachments: {:?}", attachments);
let message_empty = message.as_ref().map_or(true, |m| m.is_empty());
let attachments_empty = attachments.as_ref().map_or(true, |a| a.is_empty());
if message_empty && attachments_empty {
return Err("Message and attachments are empty".to_string());
}
let body = {
let request_message = ChatRequestMessage {
message,
attachments,
let body = if !message.is_empty() {
let message = ChatRequestMessage {
message: Some(message),
};
println!("chat_chat body: {:?}", request_message);
Some(
serde_json::to_string(&request_message)
serde_json::to_string(&message)
.map_err(|e| format!("Failed to serialize message: {}", e))?
.into(),
)
} else {
None
};
let path = format!("/chat/{}/_chat", session_id);
@@ -259,9 +314,6 @@ pub async fn chat_chat(
if let Err(err) = app_handle.emit(&client_id, line) {
log::error!("Emit failed: {:?}", err);
print!("Error sending message: {:?}", err);
let _ = app_handle.emit("chat-create-error", format!("Emit failed: {:?}", err));
}
}
@@ -313,8 +365,8 @@ pub async fn update_session_chat(
}
#[tauri::command]
pub async fn assistant_search(
_app_handle: AppHandle,
pub async fn assistant_search<R: Runtime>(
_app_handle: AppHandle<R>,
server_id: String,
query_params: Option<Vec<String>>,
) -> Result<Value, String> {
@@ -329,8 +381,8 @@ pub async fn assistant_search(
}
#[tauri::command]
pub async fn assistant_get(
_app_handle: AppHandle,
pub async fn assistant_get<R: Runtime>(
_app_handle: AppHandle<R>,
server_id: String,
assistant_id: String,
) -> Result<Value, String> {
@@ -353,8 +405,8 @@ pub async fn assistant_get(
///
/// Returns as soon as the assistant is found on any Coco server.
#[tauri::command]
pub async fn assistant_get_multi(
app_handle: AppHandle,
pub async fn assistant_get_multi<R: Runtime>(
app_handle: AppHandle<R>,
assistant_id: String,
) -> Result<Value, String> {
let search_sources = app_handle.state::<SearchSourceRegistry>();
@@ -447,8 +499,8 @@ pub fn remove_icon_fields(json: &str) -> String {
}
#[tauri::command]
pub async fn ask_ai(
app_handle: AppHandle,
pub async fn ask_ai<R: Runtime>(
app_handle: AppHandle<R>,
message: String,
server_id: String,
assistant_id: String,

View File

@@ -1,15 +1,15 @@
use std::{fs::create_dir, io::Read};
use tauri::{AppHandle, Manager};
use tauri::{Manager, Runtime};
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(tauri_app_handle: &AppHandle) -> Result<(), String> {
let autostart_manager = tauri_app_handle.autolaunch();
pub fn ensure_autostart_state_consistent(app: &mut tauri::App) -> Result<(), String> {
let autostart_manager = app.autolaunch();
let os_state = autostart_manager.is_enabled().map_err(|e| e.to_string())?;
let coco_stored_state = current_autostart(tauri_app_handle).map_err(|e| e.to_string())?;
let coco_stored_state = current_autostart(app.app_handle()).map_err(|e| e.to_string())?;
if os_state != coco_stored_state {
log::warn!(
@@ -42,7 +42,7 @@ pub fn ensure_autostart_state_consistent(tauri_app_handle: &AppHandle) -> Result
Ok(())
}
fn current_autostart(app: &tauri::AppHandle) -> Result<bool, String> {
fn current_autostart<R: Runtime>(app: &tauri::AppHandle<R>) -> Result<bool, String> {
use std::fs::File;
let path = app.path().app_config_dir().unwrap();
@@ -65,7 +65,10 @@ fn current_autostart(app: &tauri::AppHandle) -> Result<bool, String> {
}
#[tauri::command]
pub async fn change_autostart(app: tauri::AppHandle, open: bool) -> Result<(), String> {
pub async fn change_autostart<R: Runtime>(
app: tauri::AppHandle<R>,
open: bool,
) -> Result<(), String> {
use std::fs::File;
use std::io::Write;

View File

@@ -3,10 +3,7 @@ use serde_json::Value;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatRequestMessage {
#[serde(skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub attachments: Option<Vec<String>>,
}
#[allow(dead_code)]

View File

@@ -1,9 +1,7 @@
#[cfg(target_os = "macos")]
use crate::extension::built_in::window_management::actions::Action;
use crate::extension::{ExtensionPermission, ExtensionSettings};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use tauri::{AppHandle, Emitter};
use tauri::AppHandle;
use tauri::Runtime;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RichLabel {
@@ -33,63 +31,17 @@ pub struct EditorInfo {
pub timestamp: Option<String>,
}
/// 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.
/// Defines the action that would be performed when a document gets opened.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) enum OnOpened {
/// Launch the application
Application { app_path: String },
/// Open the URL.
Document { url: String },
/// Perform this WM action.
#[cfg(target_os = "macos")]
WindowManagementAction { action: Action },
/// The document is an extension.
Extension(ExtensionOnOpened),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct ExtensionOnOpened {
/// Different types of extensions have different open behaviors.
pub(crate) ty: ExtensionOnOpenedType,
/// Extensions settings. Some could affect open action.
///
/// Optional because not all extensions have their settings.
pub(crate) settings: Option<ExtensionSettings>,
/// Permission needed by this extension.
///
/// We do permission check when opening this permission. Currently, we only
/// do this to View extensions.
pub(crate) permission: Option<ExtensionPermission>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) enum ExtensionOnOpenedType {
/// Spawn a child process to run the `CommandAction`.
Command {
action: crate::extension::CommandAction,
},
/// Open the `link`.
//
// NOTE that this variant has the same definition as `struct Quicklink`, but we
// cannot use it directly, its `link` field should be deserialized/serialized
// from/to a string, but we need a JSON object here.
//
// See also the comments in `struct Quicklink`.
Quicklink {
link: crate::extension::QuicklinkLink,
open_with: Option<String>,
},
View {
/// Path to the HTML file that coco will load and render.
///
/// It should be an absolute path or Tauri cannot open it.
page: String,
},
}
impl OnOpened {
@@ -97,158 +49,62 @@ impl OnOpened {
match self {
Self::Application { app_path } => app_path.clone(),
Self::Document { url } => url.clone(),
#[cfg(target_os = "macos")]
Self::WindowManagementAction { action: _ } => {
// We don't have URL for this
String::from("N/A")
}
Self::Extension(ext_on_opened) => {
match &ext_on_opened.ty {
ExtensionOnOpenedType::Command { action } => {
const WHITESPACE: &str = " ";
let mut ret = action.exec.clone();
ret.push_str(WHITESPACE);
if let Some(ref args) = action.args {
ret.push_str(args.join(WHITESPACE).as_str());
}
ret
}
// Currently, our URL is static and does not support dynamic parameters.
// The URL of a quicklink is nearly useless without such dynamic user
// inputs, so until we have dynamic URL support, we just use "N/A".
ExtensionOnOpenedType::Quicklink { .. } => String::from("N/A"),
ExtensionOnOpenedType::View { page: _ } => {
// We currently don't have URL for this kind of extension.
String::from("N/A")
}
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
}
}
}
}
#[tauri::command]
pub(crate) async fn open(
tauri_app_handle: AppHandle,
pub(crate) async fn open<R: Runtime>(
tauri_app_handle: AppHandle<R>,
on_opened: OnOpened,
extra_args: Option<HashMap<String, String>>,
) -> Result<(), String> {
log::debug!("open({})", on_opened.url());
use crate::util::open as homemade_tauri_shell_open;
use std::process::Command;
match on_opened {
OnOpened::Application { app_path } => {
log::debug!("open application [{}]", app_path);
homemade_tauri_shell_open(tauri_app_handle.clone(), app_path).await?
}
OnOpened::Document { url } => {
log::debug!("open document [{}]", url);
homemade_tauri_shell_open(tauri_app_handle.clone(), url).await?
}
#[cfg(target_os = "macos")]
OnOpened::WindowManagementAction { action } => {
log::debug!("perform Window Management action [{:?}]", action);
crate::extension::built_in::window_management::perform_action_on_main_thread(
&tauri_app_handle,
action,
)?;
}
OnOpened::Extension(ext_on_opened) => {
// 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;
}
}
OnOpened::Command { action } => {
let mut cmd = Command::new(action.exec);
if let Some(args) = action.args {
cmd.args(args);
}
let permission = ext_on_opened.permission;
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)
);
match ext_on_opened.ty {
ExtensionOnOpenedType::Command { action } => {
log::debug!("open (execute) command [{:?}]", action);
let mut cmd = Command::new(action.exec);
if let Some(args) = action.args {
cmd.args(args);
}
let output = cmd.output().map_err(|e| e.to_string())?;
// Sometimes, we wanna see the result in logs even though it doesn't fail.
log::debug!(
"executing open(Command) result, exit code: [{}], stdout: [{}], stderr: [{}]",
output.status,
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
if !output.status.success() {
log::warn!(
"executing open(Command) failed, exit code: [{}], stdout: [{}], stderr: [{}]",
output.status,
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
return Err(format!(
"Command failed, stderr [{}]",
String::from_utf8_lossy(&output.stderr)
));
}
}
ExtensionOnOpenedType::Quicklink {
link,
open_with: opt_open_with,
} => {
let url = link.concatenate_url(&extra_args);
log::debug!("open quicklink [{}] with [{:?}]", url, opt_open_with);
cfg_if::cfg_if! {
// The `open_with` functionality is only supported on macOS, provided
// by the `open -a` command.
if #[cfg(target_os = "macos")] {
let mut cmd = Command::new("open");
if let Some(ref open_with) = opt_open_with {
cmd.arg("-a");
cmd.arg(open_with.as_str());
}
cmd.arg(&url);
let output = cmd.output().map_err(|e| format!("failed to spawn [open] due to error [{}]", e))?;
if !output.status.success() {
return Err(format!(
"failed to open with app {:?}: {}",
opt_open_with,
String::from_utf8_lossy(&output.stderr)
));
}
} else {
homemade_tauri_shell_open(tauri_app_handle.clone(), url).await?
}
}
}
ExtensionOnOpenedType::View { page } => {
/*
* Emit an event to let the frontend code open this extension.
*
* Payload `page_and_permission` contains the information needed
* to do that.
*
* See "src/pages/main/index.tsx" for more info.
*/
use serde_json::Value as Json;
use serde_json::to_value;
let page_and_permission: [Json; 2] =
[Json::String(page), to_value(permission).unwrap()];
tauri_app_handle
.emit("open_view_extension", page_and_permission)
.unwrap();
}
return Err(format!(
"Command failed, stderr [{}]",
String::from_utf8_lossy(&output.stderr)
));
}
}
}

View File

@@ -1,5 +0,0 @@
# Complete Coco extension API list grouped by its category.
fs = [
"read_dir"
]

View File

@@ -1,22 +0,0 @@
//! 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)
}

View File

@@ -1,21 +0,0 @@
//! 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
}

View File

@@ -27,8 +27,8 @@ use pizza_engine::{Engine, EngineBuilder, doc};
use serde_json::Value as Json;
use std::path::Path;
use std::path::PathBuf;
use tauri::{AppHandle, Manager, async_runtime};
use tauri_plugin_fs_pro::{IconOptions, icon, metadata};
use tauri::{AppHandle, Manager, Runtime, async_runtime};
use tauri_plugin_fs_pro::{IconOptions, icon, metadata, name};
use tauri_plugin_global_shortcut::GlobalShortcutExt;
use tauri_plugin_global_shortcut::Shortcut;
use tauri_plugin_global_shortcut::ShortcutEvent;
@@ -36,13 +36,7 @@ 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";
@@ -64,18 +58,37 @@ 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> {
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(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"),
];
}
ret
#[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
}
}
/// Helper function to return `app`'s path.
@@ -102,63 +115,26 @@ fn get_app_path(app: &App) -> String {
.expect("should be UTF-8 encoded")
}
/// 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 {
// Fall back to base name
/// 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()
} else {
let app_path = get_app_path(app);
name(app_path.into()).await
}
}
/// Helper function to return an absolute path to `app`'s icon.
///
/// On macOS/Windows, we cache icons in our data directory using the `icon()` function.
async fn get_app_icon_path(tauri_app_handle: &AppHandle, app: &App) -> Result<String, String> {
async fn get_app_icon_path<R: Runtime>(
tauri_app_handle: &AppHandle<R>,
app: &App,
) -> Result<String, String> {
let res_path = if cfg!(target_os = "linux") {
let icon_path = app
.icon_path
@@ -237,8 +213,8 @@ impl SearchSourceState for ApplicationSearchSourceState {
}
/// Index applications if they have not been indexed (by checking if `app_index_dir` exists).
async fn index_applications_if_not_indexed(
tauri_app_handle: &AppHandle,
async fn index_applications_if_not_indexed<R: Runtime>(
tauri_app_handle: &AppHandle<R>,
app_index_dir: &Path,
) -> anyhow::Result<ApplicationSearchSourceState> {
let index_exists = app_index_dir.exists();
@@ -248,17 +224,9 @@ async fn index_applications_if_not_indexed(
pizza_engine_builder.set_data_store(disk_store);
let mut schema = Schema::new();
let field_app_name_zh = Property::builder(FieldType::Text).build();
let field_app_name = Property::builder(FieldType::Text).build();
schema
.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)
.add_property(FIELD_APP_NAME, field_app_name)
.expect("no collision could happen");
let property_icon = Property::builder(FieldType::Text).index(false).build();
schema
@@ -303,39 +271,21 @@ async fn index_applications_if_not_indexed(
for app in apps.iter() {
let app_path = get_app_path(app);
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_name = get_app_name(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());
// 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 {
if app_name.is_empty() || app_name.eq(&tauri_app_handle.package_info().name) {
continue;
}
// You cannot write `app_name.clone()` within the `doc!()` macro, we should fix this.
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_name_clone = app_name.clone();
let app_path_clone = app_path.clone();
let document = doc!( app_path_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_APP_NAME => app_name_clone,
FIELD_ICON_PATH => app_icon_path,
FIELD_APP_ALIAS => app_alias,
}
@@ -344,8 +294,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 zh: '{}', app name en: '{}', app path: '{}'] due to error [{}]",
app_name_zh, app_name_en, app_path, e
"failed to index application [app name: '{}', app path: '{}'] due to error [{}]",
app_name, app_path, e
)
}
}
@@ -365,13 +315,13 @@ async fn index_applications_if_not_indexed(
}
/// Upon application start, index all the applications found in the `get_default_search_paths()`.
struct IndexAllApplicationsTask {
tauri_app_handle: AppHandle,
struct IndexAllApplicationsTask<R: Runtime> {
tauri_app_handle: AppHandle<R>,
callback: Option<tokio::sync::oneshot::Sender<Result<(), String>>>,
}
#[async_trait::async_trait(?Send)]
impl Task for IndexAllApplicationsTask {
impl<R: Runtime> Task for IndexAllApplicationsTask<R> {
fn search_source_id(&self) -> &'static str {
APPLICATION_SEARCH_SOURCE_ID
}
@@ -393,13 +343,13 @@ impl Task for IndexAllApplicationsTask {
}
}
struct ReindexAllApplicationsTask {
tauri_app_handle: AppHandle,
struct ReindexAllApplicationsTask<R: Runtime> {
tauri_app_handle: AppHandle<R>,
callback: Option<tokio::sync::oneshot::Sender<Result<(), String>>>,
}
#[async_trait::async_trait(?Send)]
impl Task for ReindexAllApplicationsTask {
impl<R: Runtime> Task for ReindexAllApplicationsTask<R> {
fn search_source_id(&self) -> &'static str {
APPLICATION_SEARCH_SOURCE_ID
}
@@ -427,14 +377,14 @@ impl Task for ReindexAllApplicationsTask {
}
}
struct SearchApplicationsTask {
tauri_app_handle: AppHandle,
struct SearchApplicationsTask<R: Runtime> {
tauri_app_handle: AppHandle<R>,
query_string: String,
callback: Option<OneshotSender<Result<SearchResult, PizzaEngineError>>>,
}
#[async_trait::async_trait(?Send)]
impl Task for SearchApplicationsTask {
impl<R: Runtime> Task for SearchApplicationsTask<R> {
fn search_source_id(&self) -> &'static str {
APPLICATION_SEARCH_SOURCE_ID
}
@@ -474,19 +424,9 @@ 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_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
"{{ \"query\": {{ \"bool\": {{ \"should\": [ {{ \"match\": {{ \"{FIELD_APP_NAME}\": {:?} }} }}, {{ \"prefix\": {{ \"{FIELD_APP_NAME}\": {:?} }} }} ] }} }} }}",
self.query_string, self.query_string
);
let state = state
@@ -574,7 +514,9 @@ impl Task for IndexNewApplicationsTask {
pub struct ApplicationSearchSource;
impl ApplicationSearchSource {
pub async fn prepare_index_and_store(app_handle: AppHandle) -> Result<(), String> {
pub async fn prepare_index_and_store<R: Runtime>(
app_handle: AppHandle<R>,
) -> Result<(), String> {
app_handle
.store(TAURI_STORE_APP_HOTKEY)
.map_err(|e| e.to_string())?;
@@ -683,7 +625,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).await;
let hits = pizza_engine_hits_to_coco_hits(search_result.hits);
Ok(QueryResponse {
source,
@@ -693,11 +635,9 @@ impl SearchSource for ApplicationSearchSource {
}
}
async fn pizza_engine_hits_to_coco_hits(
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();
};
@@ -706,43 +646,10 @@ async 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;
// 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 app_name = match document_fields.remove(FIELD_APP_NAME).unwrap() {
FieldValue::Text(string) => string,
_ => unreachable!("field name is of type Text"),
};
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,
@@ -762,7 +669,7 @@ async fn pizza_engine_hits_to_coco_hits(
}),
id: app_path.clone(),
category: Some("Application".to_string()),
title: Some(app_name),
title: Some(app_name.clone()),
icon: Some(app_icon_path),
on_opened: Some(on_opened),
url: Some(url),
@@ -776,7 +683,7 @@ async fn pizza_engine_hits_to_coco_hits(
coco_hits
}
pub fn set_app_alias(tauri_app_handle: &AppHandle, app_path: &str, alias: &str) {
pub fn set_app_alias<R: Runtime>(tauri_app_handle: &AppHandle<R>, app_path: &str, alias: &str) {
let store = tauri_app_handle
.store(TAURI_STORE_APP_ALIAS)
.unwrap_or_else(|_| panic!("store [{}] not found/loaded", TAURI_STORE_APP_ALIAS));
@@ -789,7 +696,7 @@ pub fn set_app_alias(tauri_app_handle: &AppHandle, app_path: &str, alias: &str)
// deleted while updating it.
}
fn get_app_alias(tauri_app_handle: &AppHandle, app_path: &str) -> Option<String> {
fn get_app_alias<R: Runtime>(tauri_app_handle: &AppHandle<R>, app_path: &str) -> Option<String> {
let store = tauri_app_handle
.store(TAURI_STORE_APP_ALIAS)
.unwrap_or_else(|_| panic!("store [{}] not found/loaded", TAURI_STORE_APP_ALIAS));
@@ -807,9 +714,9 @@ fn get_app_alias(tauri_app_handle: &AppHandle, app_path: &str) -> Option<String>
/// The handler that will be invoked when an application hotkey is pressed.
///
/// The `app_path` argument is for logging-only.
fn app_hotkey_handler(
fn app_hotkey_handler<R: Runtime>(
app_path: String,
) -> impl Fn(&AppHandle, &Shortcut, ShortcutEvent) + Send + Sync + 'static {
) -> impl Fn(&AppHandle<R>, &Shortcut, ShortcutEvent) + Send + Sync + 'static {
move |tauri_app_handle, _hot_key, event| {
if event.state() == ShortcutState::Pressed {
let app_path_clone = app_path.clone();
@@ -825,7 +732,7 @@ fn app_hotkey_handler(
}
/// For all the applications, if it is enabled & has hotkey set, then set it up.
pub(crate) fn set_apps_hotkey(tauri_app_handle: &AppHandle) -> Result<(), String> {
pub(crate) fn set_apps_hotkey<R: Runtime>(tauri_app_handle: &AppHandle<R>) -> Result<(), String> {
let app_hotkey_store = tauri_app_handle
.store(TAURI_STORE_APP_HOTKEY)
.unwrap_or_else(|_| panic!("store [{}] not found/loaded", TAURI_STORE_APP_HOTKEY));
@@ -849,7 +756,7 @@ pub(crate) fn set_apps_hotkey(tauri_app_handle: &AppHandle) -> Result<(), String
}
/// For all the applications, if it is enabled & has hotkey set, then unset it.
pub(crate) fn unset_apps_hotkey(tauri_app_handle: &AppHandle) -> Result<(), String> {
pub(crate) fn unset_apps_hotkey<R: Runtime>(tauri_app_handle: &AppHandle<R>) -> Result<(), String> {
let app_hotkey_store = tauri_app_handle
.store(TAURI_STORE_APP_HOTKEY)
.unwrap_or_else(|_| panic!("store [{}] not found/loaded", TAURI_STORE_APP_HOTKEY));
@@ -876,8 +783,8 @@ pub(crate) fn unset_apps_hotkey(tauri_app_handle: &AppHandle) -> Result<(), Stri
}
/// Set the hotkey but won't persist this settings change.
pub(crate) fn set_app_hotkey(
tauri_app_handle: &AppHandle,
pub(crate) fn set_app_hotkey<R: Runtime>(
tauri_app_handle: &AppHandle<R>,
app_path: &str,
hotkey: &str,
) -> Result<(), String> {
@@ -887,8 +794,8 @@ pub(crate) fn set_app_hotkey(
.map_err(|e| e.to_string())
}
pub fn register_app_hotkey(
tauri_app_handle: &AppHandle,
pub fn register_app_hotkey<R: Runtime>(
tauri_app_handle: &AppHandle<R>,
app_path: &str,
hotkey: &str,
) -> Result<(), String> {
@@ -905,7 +812,10 @@ pub fn register_app_hotkey(
Ok(())
}
pub fn unregister_app_hotkey(tauri_app_handle: &AppHandle, app_path: &str) -> Result<(), String> {
pub fn unregister_app_hotkey<R: Runtime>(
tauri_app_handle: &AppHandle<R>,
app_path: &str,
) -> Result<(), String> {
let app_hotkey_store = tauri_app_handle
.store(TAURI_STORE_APP_HOTKEY)
.unwrap_or_else(|_| panic!("store [{}] not found/loaded", TAURI_STORE_APP_HOTKEY));
@@ -945,7 +855,7 @@ pub fn unregister_app_hotkey(tauri_app_handle: &AppHandle, app_path: &str) -> Re
Ok(())
}
fn get_disabled_app_list(tauri_app_handle: &AppHandle) -> Vec<String> {
fn get_disabled_app_list<R: Runtime>(tauri_app_handle: &AppHandle<R>) -> Vec<String> {
let store = tauri_app_handle
.store(TAURI_STORE_DISABLED_APP_LIST_AND_SEARCH_PATH)
.unwrap_or_else(|_| {
@@ -982,7 +892,10 @@ pub fn is_app_search_enabled(app_path: &str) -> bool {
disabled_app_list.iter().all(|path| path != app_path)
}
pub fn disable_app_search(tauri_app_handle: &AppHandle, app_path: &str) -> Result<(), String> {
pub fn disable_app_search<R: Runtime>(
tauri_app_handle: &AppHandle<R>,
app_path: &str,
) -> Result<(), String> {
let store = tauri_app_handle
.store(TAURI_STORE_DISABLED_APP_LIST_AND_SEARCH_PATH)
.unwrap_or_else(|_| {
@@ -1026,7 +939,10 @@ pub fn disable_app_search(tauri_app_handle: &AppHandle, app_path: &str) -> Resul
Ok(())
}
pub fn enable_app_search(tauri_app_handle: &AppHandle, app_path: &str) -> Result<(), String> {
pub fn enable_app_search<R: Runtime>(
tauri_app_handle: &AppHandle<R>,
app_path: &str,
) -> Result<(), String> {
let store = tauri_app_handle
.store(TAURI_STORE_DISABLED_APP_LIST_AND_SEARCH_PATH)
.unwrap_or_else(|_| {
@@ -1068,8 +984,8 @@ pub fn enable_app_search(tauri_app_handle: &AppHandle, app_path: &str) -> Result
}
#[tauri::command]
pub async fn add_app_search_path(
tauri_app_handle: AppHandle,
pub async fn add_app_search_path<R: Runtime>(
tauri_app_handle: AppHandle<R>,
search_path: String,
) -> Result<(), String> {
let mut search_paths = get_app_search_path(tauri_app_handle.clone()).await;
@@ -1094,8 +1010,8 @@ pub async fn add_app_search_path(
}
#[tauri::command]
pub async fn remove_app_search_path(
tauri_app_handle: AppHandle,
pub async fn remove_app_search_path<R: Runtime>(
tauri_app_handle: AppHandle<R>,
search_path: String,
) -> Result<(), String> {
let mut search_paths = get_app_search_path(tauri_app_handle.clone()).await;
@@ -1120,7 +1036,7 @@ pub async fn remove_app_search_path(
}
#[tauri::command]
pub async fn get_app_search_path(tauri_app_handle: AppHandle) -> Vec<String> {
pub async fn get_app_search_path<R: Runtime>(tauri_app_handle: AppHandle<R>) -> Vec<String> {
let store = tauri_app_handle
.store(TAURI_STORE_DISABLED_APP_LIST_AND_SEARCH_PATH)
.unwrap_or_else(|_| {
@@ -1149,25 +1065,18 @@ 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};
pub async fn get_app_list<R: Runtime>(
tauri_app_handle: AppHandle<R>,
) -> Result<Vec<Extension>, String> {
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 = match lang {
Lang::zh_CN => get_app_name_zh(&app).await,
Lang::en_US => get_app_name_en(&app).await,
};
let name = get_app_name(&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;
}
@@ -1235,14 +1144,11 @@ pub async fn get_app_list(tauri_app_handle: AppHandle) -> Result<Vec<Extension>,
quicklink: None,
commands: None,
scripts: None,
views: None,
quicklinks: None,
alias: Some(alias),
hotkey,
enabled,
settings: None,
page: None,
permission: None,
screenshots: None,
url: None,
version: None,
@@ -1296,7 +1202,9 @@ pub async fn get_app_metadata(app_name: String, app_path: String) -> Result<AppM
}
#[tauri::command]
pub async fn reindex_applications(tauri_app_handle: AppHandle) -> Result<(), String> {
pub async fn reindex_applications<R: Runtime>(
tauri_app_handle: AppHandle<R>,
) -> Result<(), String> {
let (tx, rx) = tokio::sync::oneshot::channel();
let reindex_applications_task = ReindexAllApplicationsTask {
tauri_app_handle: tauri_app_handle.clone(),

View File

@@ -5,14 +5,16 @@ use crate::common::search::{QueryResponse, QuerySource, SearchQuery};
use crate::common::traits::SearchSource;
use crate::extension::LOCAL_QUERY_SOURCE_TYPE;
use async_trait::async_trait;
use tauri::AppHandle;
use tauri::{AppHandle, Runtime};
pub(crate) const QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME: &str = "Applications";
pub struct ApplicationSearchSource;
impl ApplicationSearchSource {
pub async fn prepare_index_and_store(_app_handle: AppHandle) -> Result<(), String> {
pub async fn prepare_index_and_store<R: Runtime>(
_app_handle: AppHandle<R>,
) -> Result<(), String> {
Ok(())
}
}
@@ -43,28 +45,37 @@ impl SearchSource for ApplicationSearchSource {
}
}
pub fn set_app_alias(_tauri_app_handle: &AppHandle, _app_path: &str, _alias: &str) {
pub fn set_app_alias<R: Runtime>(_tauri_app_handle: &AppHandle<R>, _app_path: &str, _alias: &str) {
unreachable!("app list should be empty, there is no way this can be invoked")
}
pub fn register_app_hotkey(
_tauri_app_handle: &AppHandle,
pub fn register_app_hotkey<R: Runtime>(
_tauri_app_handle: &AppHandle<R>,
_app_path: &str,
_hotkey: &str,
) -> Result<(), String> {
unreachable!("app list should be empty, there is no way this can be invoked")
}
pub fn unregister_app_hotkey(_tauri_app_handle: &AppHandle, _app_path: &str) -> Result<(), String> {
pub fn unregister_app_hotkey<R: Runtime>(
_tauri_app_handle: &AppHandle<R>,
_app_path: &str,
) -> Result<(), String> {
unreachable!("app list should be empty, there is no way this can be invoked")
}
pub fn disable_app_search(_tauri_app_handle: &AppHandle, _app_path: &str) -> Result<(), String> {
pub fn disable_app_search<R: Runtime>(
_tauri_app_handle: &AppHandle<R>,
_app_path: &str,
) -> Result<(), String> {
// no-op
Ok(())
}
pub fn enable_app_search(_tauri_app_handle: &AppHandle, _app_path: &str) -> Result<(), String> {
pub fn enable_app_search<R: Runtime>(
_tauri_app_handle: &AppHandle<R>,
_app_path: &str,
) -> Result<(), String> {
// no-op
Ok(())
}
@@ -74,8 +85,8 @@ pub fn is_app_search_enabled(_app_path: &str) -> bool {
}
#[tauri::command]
pub async fn add_app_search_path(
_tauri_app_handle: AppHandle,
pub async fn add_app_search_path<R: Runtime>(
_tauri_app_handle: AppHandle<R>,
_search_path: String,
) -> Result<(), String> {
// no-op
@@ -83,8 +94,8 @@ pub async fn add_app_search_path(
}
#[tauri::command]
pub async fn remove_app_search_path(
_tauri_app_handle: AppHandle,
pub async fn remove_app_search_path<R: Runtime>(
_tauri_app_handle: AppHandle<R>,
_search_path: String,
) -> Result<(), String> {
// no-op
@@ -92,37 +103,43 @@ pub async fn remove_app_search_path(
}
#[tauri::command]
pub async fn get_app_search_path(_tauri_app_handle: AppHandle) -> Vec<String> {
pub async fn get_app_search_path<R: Runtime>(_tauri_app_handle: AppHandle<R>) -> Vec<String> {
// Return an empty list
Vec::new()
}
#[tauri::command]
pub async fn get_app_list(_tauri_app_handle: AppHandle) -> Result<Vec<Extension>, String> {
pub async fn get_app_list<R: Runtime>(
_tauri_app_handle: AppHandle<R>,
) -> Result<Vec<Extension>, String> {
// Return an empty list
Ok(Vec::new())
}
#[tauri::command]
pub async fn get_app_metadata(
_tauri_app_handle: AppHandle,
pub async fn get_app_metadata<R: Runtime>(
_tauri_app_handle: AppHandle<R>,
_app_path: String,
) -> Result<AppMetadata, String> {
unreachable!("app list should be empty, there is no way this can be invoked")
}
pub(crate) fn set_apps_hotkey(_tauri_app_handle: &AppHandle) -> Result<(), String> {
pub(crate) fn set_apps_hotkey<R: Runtime>(_tauri_app_handle: &AppHandle<R>) -> Result<(), String> {
// no-op
Ok(())
}
pub(crate) fn unset_apps_hotkey(_tauri_app_handle: &AppHandle) -> Result<(), String> {
pub(crate) fn unset_apps_hotkey<R: Runtime>(
_tauri_app_handle: &AppHandle<R>,
) -> Result<(), String> {
// no-op
Ok(())
}
#[tauri::command]
pub async fn reindex_applications(_tauri_app_handle: AppHandle) -> Result<(), String> {
pub async fn reindex_applications<R: Runtime>(
_tauri_app_handle: AppHandle<R>,
) -> Result<(), String> {
// no-op
Ok(())
}

View File

@@ -1,11 +1,11 @@
//! File Search configuration entries definition and getter/setter functions.
use crate::extension::built_in::file_search::implementation::apply_config;
use serde::Deserialize;
use serde::Serialize;
use serde_json::Value;
use std::sync::LazyLock;
use tauri::AppHandle;
use tauri::Runtime;
use tauri_plugin_store::StoreExt;
// Tauri store keys for file system configuration
@@ -24,7 +24,7 @@ static HOME_DIR: LazyLock<String> = LazyLock::new(|| {
.expect("User home directory should be encoded with UTF-8")
});
#[derive(Debug, Clone, Serialize, Deserialize, Copy, PartialEq)]
#[derive(Debug, Clone, Serialize, Deserialize, Copy)]
pub enum SearchBy {
Name,
NameAndContents,
@@ -54,7 +54,7 @@ impl Default for FileSearchConfig {
}
impl FileSearchConfig {
pub(crate) fn get(tauri_app_handle: &AppHandle) -> Self {
pub(crate) fn get<R: Runtime>(tauri_app_handle: &AppHandle<R>) -> Self {
let store = tauri_app_handle
.store(TAURI_STORE_FILE_SYSTEM_CONFIG)
.unwrap_or_else(|e| {
@@ -185,32 +185,28 @@ impl FileSearchConfig {
// Tauri commands for managing file system configuration
#[tauri::command]
pub async fn get_file_system_config(tauri_app_handle: AppHandle) -> FileSearchConfig {
pub async fn get_file_system_config<R: Runtime>(
tauri_app_handle: AppHandle<R>,
) -> FileSearchConfig {
FileSearchConfig::get(&tauri_app_handle)
}
#[tauri::command]
pub async fn set_file_system_config(
tauri_app_handle: AppHandle,
pub async fn set_file_system_config<R: Runtime>(
tauri_app_handle: AppHandle<R>,
config: FileSearchConfig,
) -> Result<(), String> {
let store = tauri_app_handle
.store(TAURI_STORE_FILE_SYSTEM_CONFIG)
.map_err(|e| e.to_string())?;
store.set(TAURI_STORE_KEY_SEARCH_PATHS, config.search_paths.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_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_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(())
}

View File

@@ -1,388 +0,0 @@
//! 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"
);
}
}

View File

@@ -1,308 +0,0 @@
//! 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(())
}

View File

@@ -1,50 +0,0 @@
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());
}
}
}

View File

@@ -1,11 +1,10 @@
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::sync_get_file_icon;
use crate::util::file::get_file_icon;
use futures::stream::Stream;
use futures::stream::StreamExt;
use std::os::fd::OwnedFd;
@@ -25,7 +24,7 @@ pub(crate) async fn hits(
size: usize,
config: &FileSearchConfig,
) -> Result<Vec<(Document, f64)>, String> {
let (mut iter, _mdfind_child_process) =
let (mut iter, mut mdfind_child_process) =
execute_mdfind_query(&query_string, from, size, &config)?;
// Convert results to documents
@@ -33,7 +32,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 = sync_get_file_icon(&file_path);
let icon = get_file_icon(file_path.clone()).await;
let file_path_of_type_path = camino::Utf8Path::new(&file_path);
let r#where = file_path_of_type_path
.parent()
@@ -73,6 +72,12 @@ 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)
}
@@ -83,28 +88,13 @@ fn build_mdfind_query(query_string: &str, config: &FileSearchConfig) -> Vec<Stri
match config.search_by {
SearchBy::Name => {
// 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));
args.push(format!("kMDItemFSName == '*{}*'", query_string));
}
SearchBy::NameAndContents => {
// 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());
args.push(format!(
"kMDItemFSName == '*{}*' || kMDItemTextContent == '{}'",
query_string, query_string
));
}
}
@@ -124,9 +114,8 @@ 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. The child process will be killed
/// when this handle gets dropped, we need to keep it alive until we exhaust
/// all the query results.
/// * Child: The handle to the mdfind process, we need to kill it once we
/// collect all the results to avoid zombie processes.
fn execute_mdfind_query(
query_string: &str,
from: usize,
@@ -144,7 +133,6 @@ 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();
@@ -152,7 +140,7 @@ fn execute_mdfind_query(
.filter(move |res_path| {
std::future::ready({
match res_path {
Ok(path) => !should_be_filtered_out(&config_clone, path, false, true, true),
Ok(path) => !should_be_filtered_out(&config_clone, path),
Err(_) => {
// Don't filter out Err() values
true
@@ -166,25 +154,33 @@ fn execute_mdfind_query(
Ok((iter, child))
}
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(())
/// 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
}

View File

@@ -1,396 +1,10 @@
use cfg_if::cfg_if;
#[cfg(target_os = "macos")]
mod macos;
#[cfg(target_os = "windows")]
mod windows;
// * 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
));
}
}
// `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;

View File

@@ -3,17 +3,13 @@
//! 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::sync_get_file_icon;
use std::borrow::Borrow;
use std::path::PathBuf;
use crate::util::file::get_file_icon;
use windows::{
Win32::System::{
Com::{CLSCTX_INPROC_SERVER, CoCreateInstance},
@@ -424,7 +420,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 = sync_get_file_icon(file_path);
let icon = get_file_icon(file_path.to_string()).await;
let file_path_of_type_path = camino::Utf8Path::new(&file_path);
let r#where = file_path_of_type_path
.parent()
@@ -472,85 +468,6 @@ 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""
//

View File

@@ -1,234 +0,0 @@
//! 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
}

View File

@@ -1,30 +0,0 @@
//! 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],
};

View File

@@ -19,7 +19,7 @@ pub(crate) const PLUGIN_JSON_FILE: &str = r#"
{
"id": "File Search",
"name": "File Search",
"platforms": ["macos", "windows", "linux"],
"platforms": ["macos", "windows"],
"description": "Search files on your system",
"icon": "font_Filesearch",
"type": "extension"

View File

@@ -3,11 +3,10 @@
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;
@@ -16,12 +15,12 @@ use crate::extension::{
ExtensionBundleIdBorrowed, PLUGIN_JSON_FILE_NAME, alter_extension_json_file,
};
use anyhow::Context;
use file_search::config::FileSearchConfig;
use file_search::implementation::apply_config as file_search_apply_config;
use std::path::{Path, PathBuf};
use tauri::{AppHandle, Manager};
use tauri::{AppHandle, Manager, Runtime};
pub(crate) fn get_built_in_extension_directory(tauri_app_handle: &AppHandle) -> PathBuf {
pub(crate) fn get_built_in_extension_directory<R: Runtime>(
tauri_app_handle: &AppHandle<R>,
) -> PathBuf {
let mut resource_dir = tauri_app_handle.path().app_data_dir().expect(
"User home directory not found, which should be impossible on desktop environments",
);
@@ -137,8 +136,8 @@ async fn load_built_in_extension(
/// We only read alias/hotkey/enabled from the JSON file, we have ensured that if
/// alias/hotkey is not supported, then it will be `None`. Besides that, no further
/// validation is needed because nothing could go wrong.
pub(crate) async fn list_built_in_extensions(
tauri_app_handle: &AppHandle,
pub(crate) async fn list_built_in_extensions<R: Runtime>(
tauri_app_handle: &AppHandle<R>,
) -> Result<Vec<Extension>, String> {
let dir = get_built_in_extension_directory(tauri_app_handle);
@@ -176,33 +175,24 @@ 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(target_os = "macos")] {
built_in_extensions.push(
load_built_in_extension(
&dir,
window_management::EXTENSION_ID,
window_management::PLUGIN_JSON_FILE,
)
.await?,
);
}
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?,
);
}
}
Ok(built_in_extensions)
}
pub(super) async fn init_built_in_extension(
tauri_app_handle: &AppHandle,
pub(super) async fn init_built_in_extension<R: Runtime>(
tauri_app_handle: &AppHandle<R>,
extension: &Extension,
search_source_registry: &SearchSourceRegistry,
) -> Result<(), String> {
@@ -224,28 +214,16 @@ 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(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);
}
}
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);
}
}
}
Ok(())
@@ -255,8 +233,8 @@ pub(crate) fn is_extension_built_in(bundle_id: &ExtensionBundleIdBorrowed<'_>) -
bundle_id.developer.is_none()
}
pub(crate) async fn enable_built_in_extension(
tauri_app_handle: &AppHandle,
pub(crate) async fn enable_built_in_extension<R: Runtime>(
tauri_app_handle: &AppHandle<R>,
bundle_id: &ExtensionBundleIdBorrowed<'_>,
) -> Result<(), String> {
let search_source_registry_tauri_state = tauri_app_handle.state::<SearchSourceRegistry>();
@@ -323,51 +301,19 @@ 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(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)?;
}
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(());
}
}
}
@@ -375,8 +321,8 @@ pub(crate) async fn enable_built_in_extension(
Ok(())
}
pub(crate) async fn disable_built_in_extension(
tauri_app_handle: &AppHandle,
pub(crate) async fn disable_built_in_extension<R: Runtime>(
tauri_app_handle: &AppHandle<R>,
bundle_id: &ExtensionBundleIdBorrowed<'_>,
) -> Result<(), String> {
let search_source_registry_tauri_state = tauri_app_handle.state::<SearchSourceRegistry>();
@@ -443,44 +389,18 @@ 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(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);
if #[cfg(any(target_os = "macos", target_os = "windows"))] {
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(&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)?;
}
alter_extension_json_file(
&get_built_in_extension_directory(tauri_app_handle),
bundle_id,
update_extension,
)?;
return Ok(());
}
}
}
@@ -488,40 +408,20 @@ pub(crate) async fn disable_built_in_extension(
Ok(())
}
pub(crate) fn set_built_in_extension_alias(
tauri_app_handle: &AppHandle,
pub(crate) fn set_built_in_extension_alias<R: Runtime>(
tauri_app_handle: &AppHandle<R>,
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(
tauri_app_handle: &AppHandle,
pub(crate) fn register_built_in_extension_hotkey<R: Runtime>(
tauri_app_handle: &AppHandle<R>,
bundle_id: &ExtensionBundleIdBorrowed<'_>,
hotkey: &str,
) -> Result<(), String> {
@@ -530,34 +430,11 @@ 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(())
}
pub(crate) fn unregister_built_in_extension_hotkey(
tauri_app_handle: &AppHandle,
pub(crate) fn unregister_built_in_extension_hotkey<R: Runtime>(
tauri_app_handle: &AppHandle<R>,
bundle_id: &ExtensionBundleIdBorrowed<'_>,
) -> Result<(), String> {
if bundle_id.extension_id == application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME {
@@ -565,35 +442,6 @@ 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(())
}
@@ -633,10 +481,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,
pub(crate) async fn is_built_in_extension_enabled<R: Runtime>(
tauri_app_handle: &AppHandle<R>,
bundle_id: &ExtensionBundleIdBorrowed<'_>,
) -> Result<bool, String> {
let search_source_registry_tauri_state = tauri_app_handle.state::<SearchSourceRegistry>();
@@ -680,17 +526,9 @@ 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(target_os = "macos")] {
// Window Management
if bundle_id.extension_id == window_management::EXTENSION_ID
if #[cfg(any(target_os = "macos", target_os = "windows"))] {
if bundle_id.extension_id == file_search::EXTENSION_ID
&& bundle_id.sub_extension_id.is_none()
{
return Ok(search_source_registry_tauri_state
@@ -698,25 +536,6 @@ 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);
}
}
}

View File

@@ -1,134 +0,0 @@
#[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,
}

View File

@@ -1,638 +0,0 @@
//! This module calls macOS APIs to implement various helper functions needed by
//! to perform the defined actions.
mod private;
use std::ffi::c_uint;
use std::ffi::c_ushort;
use std::ffi::c_void;
use std::ops::Deref;
use std::ptr::NonNull;
use objc2::MainThreadMarker;
use objc2_app_kit::NSEvent;
use objc2_app_kit::NSScreen;
use objc2_app_kit::NSWorkspace;
use objc2_application_services::AXError;
use objc2_application_services::AXUIElement;
use objc2_application_services::AXValue;
use objc2_application_services::AXValueType;
use objc2_core_foundation::CFBoolean;
use objc2_core_foundation::CFRetained;
use objc2_core_foundation::CFString;
use objc2_core_foundation::CFType;
use objc2_core_foundation::CGPoint;
use objc2_core_foundation::CGRect;
use objc2_core_foundation::CGSize;
use objc2_core_foundation::Type;
use objc2_core_foundation::{CFArray, CFDictionary, CFNumber};
use objc2_core_graphics::CGError;
use objc2_core_graphics::CGEvent;
use objc2_core_graphics::CGEventFlags;
use objc2_core_graphics::CGEventTapLocation;
use objc2_core_graphics::CGEventType;
use objc2_core_graphics::CGMouseButton;
use objc2_core_graphics::CGRectGetMidX;
use objc2_core_graphics::CGRectGetMinY;
use objc2_core_graphics::CGWindowID;
use super::error::Error;
use private::CGSCopyManagedDisplaySpaces;
use private::CGSGetActiveSpace;
use private::CGSMainConnectionID;
use private::CGSSpaceID;
use std::collections::HashMap;
use std::sync::{LazyLock, Mutex};
fn intersects(r1: CGRect, r2: CGRect) -> bool {
let overlapping = !(r1.origin.x + r1.size.width < r2.origin.x
|| r1.origin.y + r1.size.height < r2.origin.y
|| r1.origin.x > r2.origin.x + r2.size.width
|| r1.origin.y > r2.origin.y + r2.size.height);
overlapping
}
/// Core graphics APIs use flipped coordinate system, while AppKit uses the
/// unflippled version, they differ in the y-axis. We need to do the conversion
/// (to `CGPoint.y`) manually.
fn flip_frame_y(main_screen_height: f64, frame_height: f64, frame_unflipped_y: f64) -> f64 {
main_screen_height - (frame_unflipped_y + frame_height)
}
/// Helper function to extract an UI element's origin.
fn get_ui_element_origin(ui_element: &CFRetained<AXUIElement>) -> Result<CGPoint, Error> {
let mut position_value: *const CFType = std::ptr::null();
let ptr_to_position_value = NonNull::new(&mut position_value).unwrap();
let position_attr = CFString::from_static_str("AXPosition");
let error = unsafe { ui_element.copy_attribute_value(&position_attr, ptr_to_position_value) };
if error != AXError::Success {
return Err(Error::AXError(error));
}
assert!(!position_value.is_null());
let position: CFRetained<AXValue> =
unsafe { CFRetained::from_raw(NonNull::new(position_value.cast_mut().cast()).unwrap()) };
let mut position_cg_point = CGPoint::ZERO;
let ptr_to_position_cg_point =
NonNull::new((&mut position_cg_point as *mut CGPoint).cast()).unwrap();
let result = unsafe { position.value(AXValueType::CGPoint, ptr_to_position_cg_point) };
assert!(result, "type mismatched");
Ok(position_cg_point)
}
/// Helper function to extract an UI element's size.
fn get_ui_element_size(ui_element: &CFRetained<AXUIElement>) -> Result<CGSize, Error> {
let mut size_value: *const CFType = std::ptr::null();
let ptr_to_size_value = NonNull::new(&mut size_value).unwrap();
let size_attr = CFString::from_static_str("AXSize");
let error = unsafe { ui_element.copy_attribute_value(&size_attr, ptr_to_size_value) };
if error != AXError::Success {
return Err(Error::AXError(error));
}
assert!(!size_value.is_null());
let size: CFRetained<AXValue> =
unsafe { CFRetained::from_raw(NonNull::new(size_value.cast_mut().cast()).unwrap()) };
let mut size_cg_size = CGSize::ZERO;
let ptr_to_size_cg_size = NonNull::new((&mut size_cg_size as *mut CGSize).cast()).unwrap();
let result = unsafe { size.value(AXValueType::CGSize, ptr_to_size_cg_size) };
assert!(result, "type mismatched");
Ok(size_cg_size)
}
/// Get the frontmost/focused window (as an UI element).
fn get_frontmost_window() -> Result<CFRetained<AXUIElement>, Error> {
let workspace = unsafe { NSWorkspace::sharedWorkspace() };
let frontmost_app =
unsafe { workspace.frontmostApplication() }.ok_or(Error::CannotFindFocusWindow)?;
let pid = unsafe { frontmost_app.processIdentifier() };
let app_element = unsafe { AXUIElement::new_application(pid) };
let mut window_element: *const CFType = std::ptr::null();
let ptr_to_window_element = NonNull::new(&mut window_element).unwrap();
let focused_window_attr = CFString::from_static_str("AXFocusedWindow");
let error =
unsafe { app_element.copy_attribute_value(&focused_window_attr, ptr_to_window_element) };
if error != AXError::Success {
return Err(Error::AXError(error));
}
assert!(!window_element.is_null());
let window_element: *mut AXUIElement = window_element.cast::<AXUIElement>().cast_mut();
let window = unsafe { CFRetained::from_raw(NonNull::new(window_element).unwrap()) };
Ok(window)
}
/// Get the CGWindowID of the frontmost/focused window.
#[allow(unused)] // In case we need it in the future
pub(crate) fn get_frontmost_window_id() -> Result<CGWindowID, Error> {
let element = get_frontmost_window()?;
let ptr: NonNull<AXUIElement> = CFRetained::as_ptr(&element);
let mut window_id_buffer: CGWindowID = 0;
let error =
unsafe { private::_AXUIElementGetWindow(ptr.as_ptr(), &mut window_id_buffer as *mut _) };
if error != AXError::Success {
return Err(Error::AXError(error));
}
Ok(window_id_buffer)
}
/// Returns the workspace ID list grouped by display. For example, suppose you
/// have 2 displays and 10 workspaces (5 workspaces per display), then this
/// function might return something like:
///
/// ```text
/// [
/// [8, 11, 12, 13, 24],
/// [519, 77, 15, 249, 414]
/// ]
/// ```
///
/// Even though this function return macOS internal space IDs, they should correspond
/// to the logical workspace that users are familiar with. The display that contains
/// workspaces `[8, 11, 12, 13, 24]` should be your main display; workspace 8 represents
/// Desktop 1, and workspace 414 represents Desktop 10.
fn workspace_ids_grouped_by_display() -> Vec<Vec<CGSSpaceID>> {
unsafe {
let mut ret = Vec::new();
let conn = CGSMainConnectionID();
let display_spaces_raw = CGSCopyManagedDisplaySpaces(conn);
let display_spaces: CFRetained<CFArray> =
CFRetained::from_raw(NonNull::new(display_spaces_raw).unwrap());
let key_spaces: CFRetained<CFString> = CFString::from_static_str("Spaces");
let key_spaces_ptr: NonNull<CFString> = CFRetained::as_ptr(&key_spaces);
let key_id64: CFRetained<CFString> = CFString::from_static_str("id64");
let key_id64_ptr: NonNull<CFString> = CFRetained::as_ptr(&key_id64);
for i in 0..display_spaces.count() {
let mut workspaces_of_this_display = Vec::new();
let dict_ref = display_spaces.value_at_index(i);
let dict: &CFDictionary = &*(dict_ref as *const CFDictionary);
let mut ptr_to_value_buffer: *const c_void = std::ptr::null();
let key_exists = dict.value_if_present(
key_spaces_ptr.as_ptr().cast::<c_void>().cast_const(),
&mut ptr_to_value_buffer as *mut _,
);
assert!(key_exists);
assert!(!ptr_to_value_buffer.is_null());
let spaces_raw: *const CFArray = ptr_to_value_buffer.cast::<CFArray>();
let spaces = &*spaces_raw;
for idx in 0..spaces.count() {
let workspace_dictionary: &CFDictionary =
&*spaces.value_at_index(idx).cast::<CFDictionary>();
let mut ptr_to_value_buffer: *const c_void = std::ptr::null();
let key_exists = workspace_dictionary.value_if_present(
key_id64_ptr.as_ptr().cast::<c_void>().cast_const(),
&mut ptr_to_value_buffer as *mut _,
);
assert!(key_exists);
assert!(!ptr_to_value_buffer.is_null());
let ptr_workspace_id = ptr_to_value_buffer.cast::<CFNumber>();
let workspace_id = (&*ptr_workspace_id).as_i32().unwrap();
workspaces_of_this_display.push(workspace_id);
}
ret.push(workspaces_of_this_display);
}
ret
}
}
/// Get the next workspace's logical ID. By logical ID, we mean the ID that
/// users are familiar with, workspace 1/2/3 and so on, rather than the internal
/// `CGSSpaceID`.
///
/// NOTE that this function returns None when the current workspace is the last
/// workspace in the current display.
pub(crate) fn get_next_workspace_logical_id() -> Option<usize> {
let window_server_connection = unsafe { CGSMainConnectionID() };
let current_workspace_id = unsafe { CGSGetActiveSpace(window_server_connection) };
// Logical ID starts from 1
let mut logical_id = 1_usize;
for workspaces_in_a_display in workspace_ids_grouped_by_display() {
for (idx, workspace_raw_id) in workspaces_in_a_display.iter().enumerate() {
if *workspace_raw_id == current_workspace_id {
// We found it, now check if it is the last workspace in this display
if idx == workspaces_in_a_display.len() - 1 {
return None;
} else {
return Some(logical_id + 1);
}
} else {
logical_id += 1;
continue;
}
}
}
unreachable!(
"unless the private API CGSGetActiveSpace() is broken, it should return an ID that is in the workspace ID list"
)
}
/// Get the previous workspace's logical ID.
///
/// See [`get_next_workspace_logical_id`] for the doc.
pub(crate) fn get_previous_workspace_logical_id() -> Option<usize> {
let window_server_connection = unsafe { CGSMainConnectionID() };
let current_workspace_id = unsafe { CGSGetActiveSpace(window_server_connection) };
// Logical ID starts from 1
let mut logical_id = 1_usize;
for workspaces_in_a_display in workspace_ids_grouped_by_display() {
for (idx, workspace_raw_id) in workspaces_in_a_display.iter().enumerate() {
if *workspace_raw_id == current_workspace_id {
// We found it, now check if it is the first workspace in this display
if idx == 0 {
return None;
} else {
// this sub operation is safe, logical_id is at least 2
return Some(logical_id - 1);
}
} else {
logical_id += 1;
continue;
}
}
}
unreachable!(
"unless the private API CGSGetActiveSpace() is broken, it should return an ID that is in the workspace ID list"
)
}
/// Move the frontmost window to the specified workspace.
///
/// Credits to the Silica library
///
/// * https://github.com/ianyh/Silica/blob/b91a18dbb822e99ce6b487d1cb4841e863139b2a/Silica/Sources/SIWindow.m#L215-L260
/// * https://github.com/ianyh/Silica/blob/b91a18dbb822e99ce6b487d1cb4841e863139b2a/Silica/Sources/SISystemWideElement.m#L29-L65
pub(crate) fn move_frontmost_window_to_workspace(space: usize) -> Result<(), Error> {
assert!(space >= 1);
if space > 16 {
return Err(Error::TooManyWorkspace);
}
let window_frame = get_frontmost_window_frame()?;
let close_button_frame = get_frontmost_window_close_button_frame()?;
let mouse_cursor_point = CGPoint::new(
unsafe { CGRectGetMidX(close_button_frame) },
window_frame.origin.y
+ (window_frame.origin.y - unsafe { CGRectGetMinY(close_button_frame) }).abs() / 2.0,
);
let mouse_move_event = unsafe {
CGEvent::new_mouse_event(
None,
CGEventType::MouseMoved,
mouse_cursor_point,
CGMouseButton::Left,
)
};
let mouse_drag_event = unsafe {
CGEvent::new_mouse_event(
None,
CGEventType::LeftMouseDragged,
mouse_cursor_point,
CGMouseButton::Left,
)
};
let mouse_down_event = unsafe {
CGEvent::new_mouse_event(
None,
CGEventType::LeftMouseDown,
mouse_cursor_point,
CGMouseButton::Left,
)
};
let mouse_up_event = unsafe {
CGEvent::new_mouse_event(
None,
CGEventType::LeftMouseUp,
mouse_cursor_point,
CGMouseButton::Left,
)
};
unsafe {
CGEvent::set_flags(mouse_move_event.as_deref(), CGEventFlags(0));
CGEvent::set_flags(mouse_down_event.as_deref(), CGEventFlags(0));
CGEvent::set_flags(mouse_up_event.as_deref(), CGEventFlags(0));
// Move the mouse into place at the window's toolbar
CGEvent::post(CGEventTapLocation::HIDEventTap, mouse_move_event.as_deref());
// Mouse down to set up the drag
CGEvent::post(CGEventTapLocation::HIDEventTap, mouse_down_event.as_deref());
// Drag event to grab hold of the window
CGEvent::post(CGEventTapLocation::HIDEventTap, mouse_drag_event.as_deref());
}
// cast is safe as space is in range [1, 16]
let hot_key: c_ushort = 118 + space as c_ushort - 1;
let mut flags: c_uint = 0;
let mut key_code: c_ushort = 0;
let error = unsafe {
private::CGSGetSymbolicHotKeyValue(hot_key, std::ptr::null_mut(), &mut key_code, &mut flags)
};
if error != CGError::Success {
return Err(Error::CGError(error));
}
unsafe {
// If the hotkey is disabled, enable it.
if !private::CGSIsSymbolicHotKeyEnabled(hot_key) {
if private::CGSSetSymbolicHotKeyEnabled(hot_key, true) != CGError::Success {
return Err(Error::CGError(error));
}
}
}
let opt_keyboard_event = unsafe { CGEvent::new_keyboard_event(None, key_code, true) };
unsafe {
// cast is safe (uint -> u64)
CGEvent::set_flags(opt_keyboard_event.as_deref(), CGEventFlags(flags as u64));
}
let keyboard_event = opt_keyboard_event.unwrap();
let event = unsafe { NSEvent::eventWithCGEvent(&keyboard_event) }.unwrap();
let keyboard_event_up = unsafe { CGEvent::new_keyboard_event(None, event.keyCode(), false) };
unsafe {
CGEvent::set_flags(keyboard_event_up.as_deref(), CGEventFlags(0));
// Send the shortcut command to get Mission Control to switch spaces from under the window.
CGEvent::post(CGEventTapLocation::HIDEventTap, event.CGEvent().as_deref());
CGEvent::post(
CGEventTapLocation::HIDEventTap,
keyboard_event_up.as_deref(),
);
}
unsafe {
// Let go of the window.
CGEvent::post(CGEventTapLocation::HIDEventTap, mouse_up_event.as_deref());
}
Ok(())
}
pub(crate) fn get_frontmost_window_origin() -> Result<CGPoint, Error> {
let frontmost_window = get_frontmost_window()?;
get_ui_element_origin(&frontmost_window)
}
pub(crate) fn get_frontmost_window_size() -> Result<CGSize, Error> {
let frontmost_window = get_frontmost_window()?;
get_ui_element_size(&frontmost_window)
}
pub(crate) fn get_frontmost_window_frame() -> Result<CGRect, Error> {
let origin = get_frontmost_window_origin()?;
let size = get_frontmost_window_size()?;
Ok(CGRect { origin, size })
}
/// Get the frontmost window's close button, then extract its frame.
fn get_frontmost_window_close_button_frame() -> Result<CGRect, Error> {
let window = get_frontmost_window()?;
let mut ptr_to_close_button: *const CFType = std::ptr::null();
let ptr_to_buffer = NonNull::new(&mut ptr_to_close_button).unwrap();
let close_button_attribute = CFString::from_static_str("AXCloseButton");
let error = unsafe { window.copy_attribute_value(&close_button_attribute, ptr_to_buffer) };
if error != AXError::Success {
return Err(Error::AXError(error));
}
assert!(!ptr_to_close_button.is_null());
let close_button_element = ptr_to_close_button.cast::<AXUIElement>().cast_mut();
let close_button = unsafe { CFRetained::from_raw(NonNull::new(close_button_element).unwrap()) };
let origin = get_ui_element_origin(&close_button)?;
let size = get_ui_element_size(&close_button)?;
Ok(CGRect { origin, size })
}
/// This function returns the "visible frame" [^1] of all the screens.
///
/// FIXME: This function relies on the [`visibleFrame()`][vf_doc] API, which
/// has 2 bugs we need to work around:
///
/// 1. It assumes the Dock is on the main display, which in reality depends on
/// how users arrange their displays and the "Dock position on screen" setting
/// entry.
/// 2. For non-main displays, it assumes that they don't have a menu bar, but macOS
/// puts a menu bar on every display.
///
///
/// [^1]: Visible frame: a rectangle defines the portion of the screen in which it
/// is currently safe to draw your apps content.
///
/// [vf_doc]: https://developer.apple.com/documentation/AppKit/NSScreen/visibleFrame
pub(crate) fn list_visible_frame_of_all_screens() -> Result<Vec<CGRect>, Error> {
let main_thread_marker = MainThreadMarker::new().ok_or(Error::NotInMainThread)?;
let screens = NSScreen::screens(main_thread_marker).to_vec();
if screens.is_empty() {
return Ok(Vec::new());
}
let main_screen = screens.first().expect("screens is not empty");
let frames = screens
.iter()
.map(|ns_screen| {
// NSScreen is an AppKit API, which uses unflipped coordinate
// system, flip it
let mut unflipped_frame = ns_screen.visibleFrame();
let flipped_frame_origin_y = flip_frame_y(
main_screen.frame().size.height,
unflipped_frame.size.height,
unflipped_frame.origin.y,
);
unflipped_frame.origin.y = flipped_frame_origin_y;
unflipped_frame
})
.collect();
Ok(frames)
}
/// Get the Visible frame of the "active screen"[^1].
///
///
/// [^1]: the screen which the frontmost window is on.
pub(crate) fn get_active_screen_visible_frame() -> Result<CGRect, Error> {
let main_thread_marker = MainThreadMarker::new().ok_or(Error::NotInMainThread)?;
let frontmost_window_frame = get_frontmost_window_frame()?;
let screens = NSScreen::screens(main_thread_marker)
.into_iter()
.collect::<Vec<_>>();
if screens.is_empty() {
return Err(Error::NoDisplay);
}
let main_screen_height = screens[0].frame().size.height;
// AppKit uses Unflipped Coordinate System, but Accessibility APIs use
// Flipped Coordinate System, we need to flip the origin of these screens.
for screen in screens {
let mut screen_frame = screen.frame();
let unflipped_y = screen_frame.origin.y;
let flipped_y = flip_frame_y(main_screen_height, screen_frame.size.height, unflipped_y);
screen_frame.origin.y = flipped_y;
if intersects(screen_frame, frontmost_window_frame) {
let mut visible_frame = screen.visibleFrame();
let flipped_y = flip_frame_y(
main_screen_height,
visible_frame.size.height,
visible_frame.origin.y,
);
visible_frame.origin.y = flipped_y;
return Ok(visible_frame);
}
}
unreachable!()
}
/// Move the frontmost window's origin to the point specified by `x` and `y`.
pub fn move_frontmost_window(x: f64, y: f64) -> Result<(), Error> {
let frontmost_window = get_frontmost_window()?;
let mut point = CGPoint::new(x, y);
let ptr_to_point = NonNull::new((&mut point as *mut CGPoint).cast::<c_void>()).unwrap();
let pos_value = unsafe { AXValue::new(AXValueType::CGPoint, ptr_to_point) }.unwrap();
let pos_attr = CFString::from_static_str("AXPosition");
let error = unsafe { frontmost_window.set_attribute_value(&pos_attr, pos_value.deref()) };
if error != AXError::Success {
return Err(Error::AXError(error));
}
Ok(())
}
/// Set the frontmost window's frame to the specified frame - adjust size and
/// location at the same time.
pub fn set_frontmost_window_frame(frame: CGRect) -> Result<(), Error> {
let frontmost_window = get_frontmost_window()?;
let mut point = frame.origin;
let ptr_to_point = NonNull::new((&mut point as *mut CGPoint).cast::<c_void>()).unwrap();
let pos_value = unsafe { AXValue::new(AXValueType::CGPoint, ptr_to_point) }.unwrap();
let pos_attr = CFString::from_static_str("AXPosition");
let error = unsafe { frontmost_window.set_attribute_value(&pos_attr, pos_value.deref()) };
if error != AXError::Success {
return Err(Error::AXError(error));
}
let mut size = frame.size;
let ptr_to_size = NonNull::new((&mut size as *mut CGSize).cast::<c_void>()).unwrap();
let size_value = unsafe { AXValue::new(AXValueType::CGSize, ptr_to_size) }.unwrap();
let size_attr = CFString::from_static_str("AXSize");
let error = unsafe { frontmost_window.set_attribute_value(&size_attr, size_value.deref()) };
if error != AXError::Success {
return Err(Error::AXError(error));
}
Ok(())
}
pub fn toggle_fullscreen() -> Result<(), Error> {
let frontmost_window = get_frontmost_window()?;
let fullscreen_attr = CFString::from_static_str("AXFullScreen");
let mut current_value_ref: *const CFType = std::ptr::null();
let error = unsafe {
frontmost_window.copy_attribute_value(
&fullscreen_attr,
NonNull::new(&mut current_value_ref).unwrap(),
)
};
// TODO: If the attribute doesn't exist, error won't be Success as well.
// Before we handle that, we need to know the error case that will be
// returned in that case.
if error != AXError::Success {
return Err(Error::AXError(error));
}
assert!(!current_value_ref.is_null());
let current_value = unsafe {
let retained_boolean: CFRetained<CFBoolean> = CFRetained::from_raw(
NonNull::new(current_value_ref.cast::<CFBoolean>().cast_mut()).unwrap(),
);
retained_boolean.as_bool()
};
let new_value = !current_value;
let new_value_ref: CFRetained<CFBoolean> = CFBoolean::new(new_value).retain();
let error =
unsafe { frontmost_window.set_attribute_value(&fullscreen_attr, new_value_ref.deref()) };
if error != AXError::Success {
return Err(Error::AXError(error));
}
Ok(())
}
static LAST_FRAME: LazyLock<Mutex<HashMap<CGWindowID, CGRect>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
pub(crate) fn set_frontmost_window_last_frame(window_id: CGWindowID, frame: CGRect) {
let mut map = LAST_FRAME.lock().unwrap();
map.insert(window_id, frame);
}
pub(crate) fn get_frontmost_window_last_frame(window_id: CGWindowID) -> Option<CGRect> {
let map = LAST_FRAME.lock().unwrap();
map.get(&window_id).cloned()
}

View File

@@ -1,70 +0,0 @@
//! 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;
}

View File

@@ -1,25 +0,0 @@
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),
}

View File

@@ -1,973 +0,0 @@
pub(crate) mod actions;
mod backend;
mod error;
pub(crate) mod on_opened;
pub(crate) mod search_source;
use crate::common::document::open;
use crate::extension::Extension;
use actions::Action;
use backend::get_active_screen_visible_frame;
use backend::get_frontmost_window_frame;
use backend::get_frontmost_window_id;
use backend::get_frontmost_window_last_frame;
use backend::get_next_workspace_logical_id;
use backend::get_previous_workspace_logical_id;
use backend::list_visible_frame_of_all_screens;
use backend::move_frontmost_window;
use backend::move_frontmost_window_to_workspace;
use backend::set_frontmost_window_frame;
use backend::set_frontmost_window_last_frame;
use backend::toggle_fullscreen;
use error::Error;
use objc2_core_foundation::{CGPoint, CGRect, CGSize};
use oneshot::channel as oneshot_channel;
use tauri::AppHandle;
use tauri::async_runtime;
use tauri_plugin_global_shortcut::GlobalShortcutExt;
use tauri_plugin_global_shortcut::ShortcutState;
pub(crate) const EXTENSION_ID: &str = "Window Management";
/// JSON file for this extension.
pub(crate) const PLUGIN_JSON_FILE: &str = include_str!("./plugin.json");
pub(crate) fn perform_action_on_main_thread(
tauri_app_handle: &AppHandle,
action: Action,
) -> Result<(), String> {
let (tx, rx) = oneshot_channel();
tauri_app_handle
.run_on_main_thread(move || {
let res = perform_action(action).map_err(|e| e.to_string());
tx.send(res)
.expect("oneshot channel receiver unexpectedly dropped");
})
.expect("tauri internal bug, channel receiver dropped");
rx.recv()
.expect("oneshot channel sender unexpectedly dropped before sending function return value")
}
/// Perform this action to the focused window.
fn perform_action(action: Action) -> Result<(), Error> {
let visible_frame = get_active_screen_visible_frame()?;
let frontmost_window_id = get_frontmost_window_id()?;
let frontmost_window_frame = get_frontmost_window_frame()?;
set_frontmost_window_last_frame(frontmost_window_id, frontmost_window_frame);
match action {
Action::TopHalf => {
let origin = CGPoint {
x: visible_frame.origin.x,
y: visible_frame.origin.y,
};
let size = CGSize {
width: visible_frame.size.width,
height: visible_frame.size.height / 2.0,
};
let new_frame = CGRect { origin, size };
set_frontmost_window_frame(new_frame)
}
Action::BottomHalf => {
let origin = CGPoint {
x: visible_frame.origin.x,
y: visible_frame.origin.y + visible_frame.size.height / 2.0,
};
let size = CGSize {
width: visible_frame.size.width,
height: visible_frame.size.height / 2.0,
};
let new_frame = CGRect { origin, size };
set_frontmost_window_frame(new_frame)
}
Action::LeftHalf => {
let origin = CGPoint {
x: visible_frame.origin.x,
y: visible_frame.origin.y,
};
let size = CGSize {
width: visible_frame.size.width / 2.0,
height: visible_frame.size.height,
};
let new_frame = CGRect { origin, size };
set_frontmost_window_frame(new_frame)
}
Action::RightHalf => {
let origin = CGPoint {
x: visible_frame.origin.x + visible_frame.size.width / 2.0,
y: visible_frame.origin.y,
};
let size = CGSize {
width: visible_frame.size.width / 2.0,
height: visible_frame.size.height,
};
let new_frame = CGRect { origin, size };
set_frontmost_window_frame(new_frame)
}
Action::CenterHalf => {
let origin = CGPoint {
x: visible_frame.origin.x + visible_frame.size.width / 4.0,
y: visible_frame.origin.y,
};
let size = CGSize {
width: visible_frame.size.width / 2.0,
height: visible_frame.size.height,
};
let new_frame = CGRect { origin, size };
set_frontmost_window_frame(new_frame)
}
Action::TopLeftQuarter => {
let origin = visible_frame.origin;
let size = CGSize {
width: visible_frame.size.width / 2.0,
height: visible_frame.size.height / 2.0,
};
let new_frame = CGRect { origin, size };
set_frontmost_window_frame(new_frame)
}
Action::TopRightQuarter => {
let origin = CGPoint {
x: visible_frame.origin.x + visible_frame.size.width / 2.0,
y: visible_frame.origin.y,
};
let size = CGSize {
width: visible_frame.size.width / 2.0,
height: visible_frame.size.height / 2.0,
};
let new_frame = CGRect { origin, size };
set_frontmost_window_frame(new_frame)
}
Action::BottomLeftQuarter => {
let origin = CGPoint {
x: visible_frame.origin.x,
y: visible_frame.origin.y + visible_frame.size.height / 2.0,
};
let size = CGSize {
width: visible_frame.size.width / 2.0,
height: visible_frame.size.height / 2.0,
};
let new_frame = CGRect { origin, size };
set_frontmost_window_frame(new_frame)
}
Action::BottomRightQuarter => {
let origin = CGPoint {
x: visible_frame.origin.x + visible_frame.size.width / 2.0,
y: visible_frame.origin.y + visible_frame.size.height / 2.0,
};
let size = CGSize {
width: visible_frame.size.width / 2.0,
height: visible_frame.size.height / 2.0,
};
let new_frame = CGRect { origin, size };
set_frontmost_window_frame(new_frame)
}
Action::TopLeftSixth => {
let origin = visible_frame.origin;
let size = CGSize {
width: visible_frame.size.width / 3.0,
height: visible_frame.size.height / 2.0,
};
let new_frame = CGRect { origin, size };
set_frontmost_window_frame(new_frame)
}
Action::TopCenterSixth => {
let origin = CGPoint {
x: visible_frame.origin.x + visible_frame.size.width / 3.0,
y: visible_frame.origin.y,
};
let size = CGSize {
width: visible_frame.size.width / 3.0,
height: visible_frame.size.height / 2.0,
};
let new_frame = CGRect { origin, size };
set_frontmost_window_frame(new_frame)
}
Action::TopRightSixth => {
let origin = CGPoint {
x: visible_frame.origin.x + visible_frame.size.width * 2.0 / 3.0,
y: visible_frame.origin.y,
};
let size = CGSize {
width: visible_frame.size.width / 3.0,
height: visible_frame.size.height / 2.0,
};
let new_frame = CGRect { origin, size };
set_frontmost_window_frame(new_frame)
}
Action::BottomLeftSixth => {
let origin = CGPoint {
x: visible_frame.origin.x,
y: visible_frame.origin.y + visible_frame.size.height / 2.0,
};
let size = CGSize {
width: visible_frame.size.width / 3.0,
height: visible_frame.size.height / 2.0,
};
let new_frame = CGRect { origin, size };
set_frontmost_window_frame(new_frame)
}
Action::BottomCenterSixth => {
let origin = CGPoint {
x: visible_frame.origin.x + visible_frame.size.width / 3.0,
y: visible_frame.origin.y + visible_frame.size.height / 2.0,
};
let size = CGSize {
width: visible_frame.size.width / 3.0,
height: visible_frame.size.height / 2.0,
};
let new_frame = CGRect { origin, size };
set_frontmost_window_frame(new_frame)
}
Action::BottomRightSixth => {
let origin = CGPoint {
x: visible_frame.origin.x + visible_frame.size.width * 2.0 / 3.0,
y: visible_frame.origin.y + visible_frame.size.height / 2.0,
};
let size = CGSize {
width: visible_frame.size.width / 3.0,
height: visible_frame.size.height / 2.0,
};
let new_frame = CGRect { origin, size };
set_frontmost_window_frame(new_frame)
}
Action::TopThird => {
let origin = visible_frame.origin;
let size = CGSize {
width: visible_frame.size.width,
height: visible_frame.size.height / 3.0,
};
let new_frame = CGRect { origin, size };
set_frontmost_window_frame(new_frame)
}
Action::MiddleThird => {
let origin = CGPoint {
x: visible_frame.origin.x,
y: visible_frame.origin.y + visible_frame.size.height / 3.0,
};
let size = CGSize {
width: visible_frame.size.width,
height: visible_frame.size.height / 3.0,
};
let new_frame = CGRect { origin, size };
set_frontmost_window_frame(new_frame)
}
Action::BottomThird => {
let origin = CGPoint {
x: visible_frame.origin.x,
y: visible_frame.origin.y + visible_frame.size.height * 2.0 / 3.0,
};
let size = CGSize {
width: visible_frame.size.width,
height: visible_frame.size.height / 3.0,
};
let new_frame = CGRect { origin, size };
set_frontmost_window_frame(new_frame)
}
Action::Center => {
let window_size = frontmost_window_frame.size;
let origin = CGPoint {
x: visible_frame.origin.x + (visible_frame.size.width - window_size.width) / 2.0,
y: visible_frame.origin.y + (visible_frame.size.height - window_size.height) / 2.0,
};
move_frontmost_window(origin.x, origin.y)
}
Action::FirstFourth => {
let origin = visible_frame.origin;
let size = CGSize {
width: visible_frame.size.width / 4.0,
height: visible_frame.size.height,
};
let new_frame = CGRect { origin, size };
set_frontmost_window_frame(new_frame)
}
Action::SecondFourth => {
let origin = CGPoint {
x: visible_frame.origin.x + visible_frame.size.width / 4.0,
y: visible_frame.origin.y,
};
let size = CGSize {
width: visible_frame.size.width / 4.0,
height: visible_frame.size.height,
};
let new_frame = CGRect { origin, size };
set_frontmost_window_frame(new_frame)
}
Action::ThirdFourth => {
let origin = CGPoint {
x: visible_frame.origin.x + visible_frame.size.width * 2.0 / 4.0,
y: visible_frame.origin.y,
};
let size = CGSize {
width: visible_frame.size.width / 4.0,
height: visible_frame.size.height,
};
let new_frame = CGRect { origin, size };
set_frontmost_window_frame(new_frame)
}
Action::LastFourth => {
let origin = CGPoint {
x: visible_frame.origin.x + visible_frame.size.width * 3.0 / 4.0,
y: visible_frame.origin.y,
};
let size = CGSize {
width: visible_frame.size.width / 4.0,
height: visible_frame.size.height,
};
let new_frame = CGRect { origin, size };
set_frontmost_window_frame(new_frame)
}
Action::FirstThird => {
let origin = CGPoint {
x: visible_frame.origin.x,
y: visible_frame.origin.y,
};
let size = CGSize {
width: visible_frame.size.width / 3.0,
height: visible_frame.size.height,
};
let new_frame = CGRect { origin, size };
set_frontmost_window_frame(new_frame)
}
Action::CenterThird => {
let origin = CGPoint {
x: visible_frame.origin.x + visible_frame.size.width / 3.0,
y: visible_frame.origin.y,
};
let size = CGSize {
width: visible_frame.size.width / 3.0,
height: visible_frame.size.height,
};
let new_frame = CGRect { origin, size };
set_frontmost_window_frame(new_frame)
}
Action::LastThird => {
let origin = CGPoint {
x: visible_frame.origin.x + visible_frame.size.width * 2.0 / 3.0,
y: visible_frame.origin.y,
};
let size = CGSize {
width: visible_frame.size.width / 3.0,
height: visible_frame.size.height,
};
let new_frame = CGRect { origin, size };
set_frontmost_window_frame(new_frame)
}
Action::FirstTwoThirds => {
let origin = CGPoint {
x: visible_frame.origin.x,
y: visible_frame.origin.y,
};
let size = CGSize {
width: visible_frame.size.width * 2.0 / 3.0,
height: visible_frame.size.height,
};
let new_frame = CGRect { origin, size };
set_frontmost_window_frame(new_frame)
}
Action::CenterTwoThirds => {
let origin = CGPoint {
x: visible_frame.origin.x + visible_frame.size.width / 6.0,
y: visible_frame.origin.y,
};
let size = CGSize {
width: visible_frame.size.width * 2.0 / 3.0,
height: visible_frame.size.height,
};
let new_frame = CGRect { origin, size };
set_frontmost_window_frame(new_frame)
}
Action::LastTwoThirds => {
let origin = CGPoint {
x: visible_frame.origin.x + visible_frame.size.width / 3.0,
y: visible_frame.origin.y,
};
let size = CGSize {
width: visible_frame.size.width * 2.0 / 3.0,
height: visible_frame.size.height,
};
let new_frame = CGRect { origin, size };
set_frontmost_window_frame(new_frame)
}
Action::FirstThreeFourths => {
let origin = CGPoint {
x: visible_frame.origin.x,
y: visible_frame.origin.y,
};
let size = CGSize {
width: visible_frame.size.width * 3.0 / 4.0,
height: visible_frame.size.height,
};
let new_frame = CGRect { origin, size };
set_frontmost_window_frame(new_frame)
}
Action::CenterThreeFourths => {
let origin = CGPoint {
x: visible_frame.origin.x + visible_frame.size.width / 8.0,
y: visible_frame.origin.y,
};
let size = CGSize {
width: visible_frame.size.width * 3.0 / 4.0,
height: visible_frame.size.height,
};
let new_frame = CGRect { origin, size };
set_frontmost_window_frame(new_frame)
}
Action::LastThreeFourths => {
let origin = CGPoint {
x: visible_frame.origin.x + visible_frame.size.width / 4.0,
y: visible_frame.origin.y,
};
let size = CGSize {
width: visible_frame.size.width * 3.0 / 4.0,
height: visible_frame.size.height,
};
let new_frame = CGRect { origin, size };
set_frontmost_window_frame(new_frame)
}
Action::TopThreeFourths => {
let origin = CGPoint {
x: visible_frame.origin.x,
y: visible_frame.origin.y,
};
let size = CGSize {
width: visible_frame.size.width,
height: visible_frame.size.height * 3.0 / 4.0,
};
let new_frame = CGRect { origin, size };
set_frontmost_window_frame(new_frame)
}
Action::BottomThreeFourths => {
let origin = CGPoint {
x: visible_frame.origin.x,
y: visible_frame.origin.y + visible_frame.size.height / 4.0,
};
let size = CGSize {
width: visible_frame.size.width,
height: visible_frame.size.height * 3.0 / 4.0,
};
let new_frame = CGRect { origin, size };
set_frontmost_window_frame(new_frame)
}
Action::TopTwoThirds => {
let origin = CGPoint {
x: visible_frame.origin.x,
y: visible_frame.origin.y,
};
let size = CGSize {
width: visible_frame.size.width,
height: visible_frame.size.height * 2.0 / 3.0,
};
let new_frame = CGRect { origin, size };
set_frontmost_window_frame(new_frame)
}
Action::BottomTwoThirds => {
let origin = CGPoint {
x: visible_frame.origin.x,
y: visible_frame.origin.y + visible_frame.size.height / 3.0,
};
let size = CGSize {
width: visible_frame.size.width,
height: visible_frame.size.height * 2.0 / 3.0,
};
let new_frame = CGRect { origin, size };
set_frontmost_window_frame(new_frame)
}
Action::TopCenterTwoThirds => {
let origin = CGPoint {
x: visible_frame.origin.x + visible_frame.size.width / 6.0,
y: visible_frame.origin.y,
};
let size = CGSize {
width: visible_frame.size.width * 2.0 / 3.0,
height: visible_frame.size.height * 2.0 / 3.0,
};
let new_frame = CGRect { origin, size };
set_frontmost_window_frame(new_frame)
}
Action::TopFirstFourth => {
let origin = CGPoint {
x: visible_frame.origin.x,
y: visible_frame.origin.y,
};
let size = CGSize {
width: visible_frame.size.width,
height: visible_frame.size.height / 4.0,
};
let new_frame = CGRect { origin, size };
set_frontmost_window_frame(new_frame)
}
Action::TopSecondFourth => {
let origin = CGPoint {
x: visible_frame.origin.x,
y: visible_frame.origin.y + visible_frame.size.height / 4.0,
};
let size = CGSize {
width: visible_frame.size.width,
height: visible_frame.size.height / 4.0,
};
let new_frame = CGRect { origin, size };
set_frontmost_window_frame(new_frame)
}
Action::TopThirdFourth => {
let origin = CGPoint {
x: visible_frame.origin.x,
y: visible_frame.origin.y + visible_frame.size.height * 2.0 / 4.0,
};
let size = CGSize {
width: visible_frame.size.width,
height: visible_frame.size.height / 4.0,
};
let new_frame = CGRect { origin, size };
set_frontmost_window_frame(new_frame)
}
Action::TopLastFourth => {
let origin = CGPoint {
x: visible_frame.origin.x,
y: visible_frame.origin.y + visible_frame.size.height * 3.0 / 4.0,
};
let size = CGSize {
width: visible_frame.size.width,
height: visible_frame.size.height / 4.0,
};
let new_frame = CGRect { origin, size };
set_frontmost_window_frame(new_frame)
}
Action::MakeLarger => {
let window_origin = frontmost_window_frame.origin;
let window_size = frontmost_window_frame.size;
let delta_width = 20_f64;
let delta_height = window_size.height / window_size.width * delta_width;
let delta_origin_x = delta_width / 2.0;
let delta_origin_y = delta_height / 2.0;
let new_width = {
let possible_value = window_size.width + delta_width;
if possible_value > visible_frame.size.width {
visible_frame.size.width
} else {
possible_value
}
};
let new_height = {
let possible_value = window_size.height + delta_height;
if possible_value > visible_frame.size.height {
visible_frame.size.height
} else {
possible_value
}
};
let new_origin_x = {
let possible_value = window_origin.x - delta_origin_x;
if possible_value < visible_frame.origin.x {
visible_frame.origin.x
} else {
possible_value
}
};
let new_origin_y = {
let possible_value = window_origin.y - delta_origin_y;
if possible_value < visible_frame.origin.y {
visible_frame.origin.y
} else {
possible_value
}
};
let origin = CGPoint {
x: new_origin_x,
y: new_origin_y,
};
let size = CGSize {
width: new_width,
height: new_height,
};
let new_frame = CGRect { origin, size };
set_frontmost_window_frame(new_frame)
}
Action::MakeSmaller => {
let window_origin = frontmost_window_frame.origin;
let window_size = frontmost_window_frame.size;
let delta_width = 20_f64;
let delta_height = window_size.height / window_size.width * delta_width;
let delta_origin_x = delta_width / 2.0;
let delta_origin_y = delta_height / 2.0;
let origin = CGPoint {
x: window_origin.x + delta_origin_x,
y: window_origin.y + delta_origin_y,
};
let size = CGSize {
width: window_size.width - delta_width,
height: window_size.height - delta_height,
};
let new_frame = CGRect { origin, size };
set_frontmost_window_frame(new_frame)
}
Action::AlmostMaximize => {
let new_size = CGSize {
width: visible_frame.size.width * 0.9,
height: visible_frame.size.height * 0.9,
};
let new_origin = CGPoint {
x: visible_frame.origin.x + (visible_frame.size.width * 0.1),
y: visible_frame.origin.y + (visible_frame.size.height * 0.1),
};
let new_frame = CGRect {
origin: new_origin,
size: new_size,
};
set_frontmost_window_frame(new_frame)
}
Action::Maximize => {
let new_frame = CGRect {
origin: visible_frame.origin,
size: visible_frame.size,
};
set_frontmost_window_frame(new_frame)
}
Action::MaximizeWidth => {
let window_origin = frontmost_window_frame.origin;
let window_size = frontmost_window_frame.size;
let origin = CGPoint {
x: visible_frame.origin.x,
y: window_origin.y,
};
let size = CGSize {
width: visible_frame.size.width,
height: window_size.height,
};
let new_frame = CGRect { origin, size };
set_frontmost_window_frame(new_frame)
}
Action::MaximizeHeight => {
let window_origin = frontmost_window_frame.origin;
let window_size = frontmost_window_frame.size;
let origin = CGPoint {
x: window_origin.x,
y: visible_frame.origin.y,
};
let size = CGSize {
width: window_size.width,
height: visible_frame.size.height,
};
let new_frame = CGRect { origin, size };
set_frontmost_window_frame(new_frame)
}
Action::MoveUp => {
let window_origin = frontmost_window_frame.origin;
let new_y = (window_origin.y - 10.0).max(visible_frame.origin.y);
move_frontmost_window(window_origin.x, new_y)
}
Action::MoveDown => {
let window_origin = frontmost_window_frame.origin;
let window_size = frontmost_window_frame.size;
let new_y = (window_origin.y + 10.0)
.min(visible_frame.origin.y + visible_frame.size.height - window_size.height);
move_frontmost_window(window_origin.x, new_y)
}
Action::MoveLeft => {
let window_origin = frontmost_window_frame.origin;
let new_x = (window_origin.x - 10.0).max(visible_frame.origin.x);
move_frontmost_window(new_x, window_origin.y)
}
Action::MoveRight => {
let window_origin = frontmost_window_frame.origin;
let window_size = frontmost_window_frame.size;
let new_x = (window_origin.x + 10.0)
.min(visible_frame.origin.x + visible_frame.size.width - window_size.width);
move_frontmost_window(new_x, window_origin.y)
}
Action::NextDesktop => {
let Some(next_workspace_logical_id) = get_next_workspace_logical_id() else {
// nothing to do
return Ok(());
};
move_frontmost_window_to_workspace(next_workspace_logical_id)
}
Action::PreviousDesktop => {
let Some(previous_workspace_logical_id) = get_previous_workspace_logical_id() else {
// nothing to do
return Ok(());
};
// Now let's switch the workspace
move_frontmost_window_to_workspace(previous_workspace_logical_id)
}
Action::NextDisplay => {
const TOO_MANY_MONITORS: &str = "I don't think you can have so many monitors";
let frames = list_visible_frame_of_all_screens()?;
let n_frames = frames.len();
if n_frames == 0 {
return Err(Error::NoDisplay);
}
if n_frames == 1 {
return Ok(());
}
let index = frames
.iter()
.position(|fr| fr == &visible_frame)
.expect("active screen should be in the list");
let new_index: usize = {
let index_i32: i32 = index.try_into().expect(TOO_MANY_MONITORS);
let index_i32_plus_one = index_i32.checked_add(1).expect(TOO_MANY_MONITORS);
let final_value = index_i32_plus_one % n_frames as i32;
final_value
.try_into()
.expect("final value should be positive")
};
let new_frame = frames[new_index];
set_frontmost_window_frame(new_frame)
}
Action::PreviousDisplay => {
const TOO_MANY_MONITORS: &str = "I don't think you can have so many monitors";
let frames = list_visible_frame_of_all_screens()?;
let n_frames = frames.len();
if n_frames == 0 {
return Err(Error::NoDisplay);
}
if n_frames == 1 {
return Ok(());
}
let index = frames
.iter()
.position(|fr| fr == &visible_frame)
.expect("active screen should be in the list");
let new_index: usize = {
let index_i32: i32 = index.try_into().expect(TOO_MANY_MONITORS);
let index_i32_minus_one = index_i32 - 1;
let n_frames_i32: i32 = n_frames.try_into().expect(TOO_MANY_MONITORS);
let final_value = (index_i32_minus_one + n_frames_i32) % n_frames_i32;
final_value
.try_into()
.expect("final value should be positive")
};
let new_frame = frames[new_index];
set_frontmost_window_frame(new_frame)
}
Action::Restore => {
let Some(previous_frame) = get_frontmost_window_last_frame(frontmost_window_id) else {
// Previous frame found, Nothing to do
return Ok(());
};
set_frontmost_window_frame(previous_frame)
}
Action::ToggleFullscreen => toggle_fullscreen(),
}
}
pub(crate) fn set_up_commands_hotkeys(
tauri_app_handle: &AppHandle,
wm_extension: &Extension,
) -> Result<(), String> {
for command in wm_extension
.commands
.as_ref()
.expect("Window Management extension has commands")
.iter()
.filter(|cmd| cmd.enabled)
{
if let Some(ref hotkey) = command.hotkey {
let on_opened = on_opened::on_opened(&command.id);
let extension_id_clone = command.id.clone();
tauri_app_handle
.global_shortcut()
.on_shortcut(hotkey.as_str(), move |tauri_app_handle, _hotkey, event| {
let on_opened_clone = on_opened.clone();
let extension_id_clone = extension_id_clone.clone();
let app_handle_clone = tauri_app_handle.clone();
if event.state() == ShortcutState::Pressed {
async_runtime::spawn(async move {
let result = open(app_handle_clone, on_opened_clone, None).await;
if let Err(msg) = result {
log::warn!(
"failed to open extension [{}], error [{}]",
extension_id_clone,
msg
);
}
});
}
})
.map_err(|e| e.to_string())?;
}
}
Ok(())
}
pub(crate) fn unset_commands_hotkeys(
tauri_app_handle: &AppHandle,
wm_extension: &Extension,
) -> Result<(), String> {
for command in wm_extension
.commands
.as_ref()
.expect("Window Management extension has commands")
.iter()
.filter(|cmd| cmd.enabled)
{
if let Some(ref hotkey) = command.hotkey {
tauri_app_handle
.global_shortcut()
.unregister(hotkey.as_str())
.map_err(|e| e.to_string())?;
}
}
Ok(())
}
pub(crate) fn set_up_command_hotkey(
tauri_app_handle: &AppHandle,
wm_extension: &Extension,
command_id: &str,
) -> Result<(), String> {
let commands = wm_extension
.commands
.as_ref()
.expect("Window Management has commands");
let opt_command = commands.iter().find(|ext| ext.id == command_id);
let Some(command) = opt_command else {
panic!("Window Management command does not exist {}", command_id);
};
if let Some(ref hotkey) = command.hotkey {
let on_opened = on_opened::on_opened(&command.id);
let extension_id_clone = command.id.clone();
tauri_app_handle
.global_shortcut()
.on_shortcut(hotkey.as_str(), move |tauri_app_handle, _hotkey, event| {
let on_opened_clone = on_opened.clone();
let extension_id_clone = extension_id_clone.clone();
let app_handle_clone = tauri_app_handle.clone();
if event.state() == ShortcutState::Pressed {
async_runtime::spawn(async move {
let result = open(app_handle_clone, on_opened_clone, None).await;
if let Err(msg) = result {
log::warn!(
"failed to open extension [{}], error [{}]",
extension_id_clone,
msg
);
}
});
}
})
.map_err(|e| e.to_string())?;
}
Ok(())
}
pub(crate) fn unset_command_hotkey(
tauri_app_handle: &AppHandle,
wm_extension: &Extension,
command_id: &str,
) -> Result<(), String> {
let commands = wm_extension
.commands
.as_ref()
.expect("Window Management has commands");
let opt_command = commands.iter().find(|ext| ext.id == command_id);
let Some(command) = opt_command else {
panic!("Window Management command does not exist {}", command_id);
};
if let Some(ref hotkey) = command.hotkey {
tauri_app_handle
.global_shortcut()
.unregister(hotkey.as_str())
.map_err(|e| e.to_string())?;
}
Ok(())
}
pub(crate) fn register_command_hotkey(
tauri_app_handle: &AppHandle,
command_id: &str,
hotkey: &str,
) -> Result<(), String> {
let on_opened = on_opened::on_opened(&command_id);
let extension_id_clone = command_id.to_string();
tauri_app_handle
.global_shortcut()
.on_shortcut(hotkey, move |tauri_app_handle, _hotkey, event| {
let on_opened_clone = on_opened.clone();
let extension_id_clone = extension_id_clone.clone();
let app_handle_clone = tauri_app_handle.clone();
if event.state() == ShortcutState::Pressed {
async_runtime::spawn(async move {
let result = open(app_handle_clone, on_opened_clone, None).await;
if let Err(msg) = result {
log::warn!(
"failed to open extension [{}], error [{}]",
extension_id_clone,
msg
);
}
});
}
})
.map_err(|e| e.to_string())?;
Ok(())
}
pub(crate) fn unregister_command_hotkey(
tauri_app_handle: &AppHandle,
wm_extension: &Extension,
command_id: &str,
) -> Result<(), String> {
let commands = wm_extension
.commands
.as_ref()
.expect("Window Management has commands");
let opt_command = commands.iter().find(|ext| ext.id == command_id);
let Some(command) = opt_command else {
panic!("Window Management command does not exist {}", command_id);
};
let Some(ref hotkey) = command.hotkey else {
return Ok(());
};
tauri_app_handle
.global_shortcut()
.unregister(hotkey.as_str())
.map_err(|e| e.to_string())?;
Ok(())
}

View File

@@ -1,10 +0,0 @@
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 }
}

View File

@@ -1,415 +0,0 @@
{
"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"
}
]
}

View File

@@ -1,127 +0,0 @@
use super::EXTENSION_ID;
use crate::common::document::{DataSourceReference, Document};
use crate::common::{
error::SearchError,
search::{QueryResponse, QuerySource, SearchQuery},
traits::SearchSource,
};
use crate::extension::built_in::{get_built_in_extension_directory, load_extension_from_json_file};
use crate::extension::{ExtensionType, LOCAL_QUERY_SOURCE_TYPE, calculate_text_similarity};
use async_trait::async_trait;
use hostname;
use tauri::AppHandle;
/// A search source to allow users to search WM actions.
pub(crate) struct WindowManagementSearchSource;
#[async_trait]
impl SearchSource for WindowManagementSearchSource {
fn get_type(&self) -> QuerySource {
QuerySource {
r#type: LOCAL_QUERY_SOURCE_TYPE.into(),
name: hostname::get()
.unwrap_or(EXTENSION_ID.into())
.to_string_lossy()
.into(),
id: EXTENSION_ID.into(),
}
}
async fn search(
&self,
tauri_app_handle: AppHandle,
query: SearchQuery,
) -> Result<QueryResponse, SearchError> {
let Some(query_string) = query.query_strings.get("query") else {
return Ok(QueryResponse {
source: self.get_type(),
hits: Vec::new(),
total_hits: 0,
});
};
let from = usize::try_from(query.from).expect("from too big");
let size = usize::try_from(query.size).expect("size too big");
let query_string = query_string.trim();
if query_string.is_empty() {
return Ok(QueryResponse {
source: self.get_type(),
hits: Vec::new(),
total_hits: 0,
});
}
let query_string_lowercase = query_string.to_lowercase();
let extension = load_extension_from_json_file(
&get_built_in_extension_directory(&tauri_app_handle),
super::EXTENSION_ID,
)
.map_err(SearchError::InternalError)?;
let commands = extension.commands.expect("this extension has commands");
let mut hits: Vec<(Document, f64)> = Vec::new();
// We know they are all commands
let command_type_string = ExtensionType::Command.to_string();
for command in commands.iter().filter(|ext| ext.enabled) {
let score = {
let mut score = 0_f64;
if let Some(name_score) =
calculate_text_similarity(&query_string_lowercase, &command.name.to_lowercase())
{
score += name_score;
}
if let Some(ref alias) = command.alias {
if let Some(alias_score) =
calculate_text_similarity(&query_string_lowercase, &alias.to_lowercase())
{
score += alias_score;
}
}
score
};
if score > 0.0 {
let on_opened = super::on_opened::on_opened(&command.id);
let url = on_opened.url();
let document = Document {
id: command.id.clone(),
title: Some(command.name.clone()),
icon: Some(command.icon.clone()),
on_opened: Some(on_opened),
url: Some(url),
category: Some(command_type_string.clone()),
source: Some(DataSourceReference {
id: Some(command_type_string.clone()),
name: Some(command_type_string.clone()),
icon: None,
r#type: Some(command_type_string.clone()),
}),
..Default::default()
};
hits.push((document, score));
}
}
hits.sort_by(|(_, score_a), (_, score_b)| {
score_a
.partial_cmp(&score_b)
.expect("expect no NAN/INFINITY/...")
});
let total_hits = hits.len();
let from_size_applied = hits.into_iter().skip(from).take(size).collect();
Ok(QueryResponse {
source: self.get_type(),
hits: from_size_applied,
total_hits,
})
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,789 +0,0 @@
//! Coco has 4 sources of `plugin.json` to check and validate:
//!
//! 1. From coco-extensions repository
//!
//! Granted, Coco APP won't check these files directly, but the code here
//! will run in that repository's CI to prevent errors in the first place.
//!
//! 2. From the "<data directory>/third_party_extensions" directory
//! 3. Imported via "Import Local Extension"
//! 4. Downloaded from the "store/extension/<extension ID>/_download" API
//!
//! This file contains the checks that are general enough to be applied to all
//! these 4 sources
use crate::extension::Extension;
use crate::extension::ExtensionType;
use crate::util::platform::Platform;
use std::collections::HashSet;
pub(crate) fn general_check(extension: &Extension) -> Result<(), String> {
// Check main extension
check_main_extension_only(extension)?;
check_main_extension_or_sub_extension(extension, &format!("extension [{}]", extension.id))?;
// `None` if `extension` is compatible with all the platforms. Otherwise `Some(limited_platforms)`
let limited_supported_platforms = match extension.platforms.as_ref() {
Some(platforms) => {
if platforms.len() == Platform::num_of_supported_platforms() {
None
} else {
Some(platforms)
}
}
None => None,
};
// Check sub extensions
let commands = match extension.commands {
Some(ref v) => v.as_slice(),
None => &[],
};
let scripts = match extension.scripts {
Some(ref v) => v.as_slice(),
None => &[],
};
let quicklinks = match extension.quicklinks {
Some(ref v) => v.as_slice(),
None => &[],
};
let views = match extension.views {
Some(ref v) => v.as_slice(),
None => &[],
};
let sub_extensions = [commands, scripts, quicklinks, views].concat();
let mut sub_extension_ids = HashSet::new();
for sub_extension in sub_extensions.iter() {
check_sub_extension_only(&extension.id, sub_extension, limited_supported_platforms)?;
check_main_extension_or_sub_extension(
extension,
&format!("sub-extension [{}-{}]", extension.id, sub_extension.id),
)?;
if !sub_extension_ids.insert(sub_extension.id.as_str()) {
// extension ID already exists
return Err(format!(
"sub-extension with ID [{}] already exists",
sub_extension.id
));
}
}
Ok(())
}
/// This checks the main extension only, it won't check sub-extensions.
fn check_main_extension_only(extension: &Extension) -> Result<(), String> {
// Group and Extension cannot have alias
if extension.alias.is_some() {
if extension.r#type == ExtensionType::Group || extension.r#type == ExtensionType::Extension
{
return Err(format!(
"invalid extension [{}], extension of type [{:?}] cannot have alias",
extension.id, extension.r#type
));
}
}
// Group and Extension cannot have hotkey
if extension.hotkey.is_some() {
if extension.r#type == ExtensionType::Group || extension.r#type == ExtensionType::Extension
{
return Err(format!(
"invalid extension [{}], extension of type [{:?}] cannot have hotkey",
extension.id, extension.r#type
));
}
}
if extension.commands.is_some()
|| extension.scripts.is_some()
|| extension.quicklinks.is_some()
|| extension.views.is_some()
{
if extension.r#type != ExtensionType::Group && extension.r#type != ExtensionType::Extension
{
return Err(format!(
"invalid extension [{}], only extension of type [Group] and [Extension] can have sub-extensions",
extension.id,
));
}
}
if extension.settings.is_some() {
// Sub-extensions are all searchable, so this check is only for main extensions.
if !extension.searchable() {
return Err(format!(
"invalid extension {}, field [settings] is currently only allowed in searchable extension, this type of extension is not searchable [{}]",
extension.id, extension.r#type
));
}
}
Ok(())
}
fn check_sub_extension_only(
extension_id: &str,
sub_extension: &Extension,
limited_platforms: Option<&HashSet<Platform>>,
) -> Result<(), String> {
if sub_extension.r#type == ExtensionType::Group
|| sub_extension.r#type == ExtensionType::Extension
{
return Err(format!(
"invalid sub-extension [{}-{}]: sub-extensions should not be of type [Group] or [Extension]",
extension_id, sub_extension.id
));
}
if sub_extension.commands.is_some()
|| sub_extension.scripts.is_some()
|| sub_extension.quicklinks.is_some()
|| sub_extension.views.is_some()
{
return Err(format!(
"invalid sub-extension [{}-{}]: fields [commands/scripts/quicklinks/views] should not be set in sub-extensions",
extension_id, sub_extension.id
));
}
if sub_extension.developer.is_some() {
return Err(format!(
"invalid sub-extension [{}-{}]: field [developer] should not be set in sub-extensions",
extension_id, sub_extension.id
));
}
if let Some(platforms_supported_by_main_extension) = limited_platforms {
match sub_extension.platforms {
Some(ref platforms_supported_by_sub_extension) => {
let diff = platforms_supported_by_sub_extension
.difference(&platforms_supported_by_main_extension)
.into_iter()
.map(|p| p.to_string())
.collect::<Vec<String>>();
if !diff.is_empty() {
return Err(format!(
"invalid sub-extension [{}-{}]: it supports platforms {:?} that are not supported by the main extension",
extension_id, sub_extension.id, diff
));
}
}
None => {
// if `sub_extension.platform` is None, it means it has the same value
// as main extension's `platforms` field, so we don't need to check it.
}
}
}
Ok(())
}
fn check_main_extension_or_sub_extension(
extension: &Extension,
identifier: &str,
) -> Result<(), String> {
// If field `action` is Some, then it should be a Command
if extension.action.is_some() && extension.r#type != ExtensionType::Command {
return Err(format!(
"invalid {}, field [action] is set for a non-Command extension",
identifier
));
}
if extension.r#type == ExtensionType::Command && extension.action.is_none() {
return Err(format!(
"invalid {}, field [action] should be set for a Command extension",
identifier
));
}
// If field `quicklink` is Some, then it should be a Quicklink
if extension.quicklink.is_some() && extension.r#type != ExtensionType::Quicklink {
return Err(format!(
"invalid {}, field [quicklink] is set for a non-Quicklink extension",
identifier
));
}
if extension.r#type == ExtensionType::Quicklink && extension.quicklink.is_none() {
return Err(format!(
"invalid {}, field [quicklink] should be set for a Quicklink extension",
identifier
));
}
// If field `page` is Some, then it should be a View
if extension.page.is_some() && extension.r#type != ExtensionType::View {
return Err(format!(
"invalid {}, field [page] is set for a non-View extension",
identifier
));
}
if extension.r#type == ExtensionType::View && extension.page.is_none() {
return Err(format!(
"invalid {}, field [page] should be set for a View extension",
identifier
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::extension::{
CommandAction, ExtensionSettings, Quicklink, QuicklinkLink, QuicklinkLinkComponent,
};
/// Helper function to create a basic valid extension
fn create_basic_extension(id: &str, extension_type: ExtensionType) -> Extension {
let page = if extension_type == ExtensionType::View {
Some("index.html".into())
} else {
None
};
Extension {
id: id.to_string(),
name: "Test Extension".to_string(),
developer: None,
platforms: None,
description: "Test description".to_string(),
icon: "test-icon.png".to_string(),
r#type: extension_type,
action: None,
quicklink: None,
commands: None,
scripts: None,
quicklinks: None,
views: None,
alias: None,
hotkey: None,
enabled: true,
page,
permission: None,
settings: None,
screenshots: None,
url: None,
version: None,
}
}
/// Helper function to create a command action
fn create_command_action() -> CommandAction {
CommandAction {
exec: "echo".to_string(),
args: Some(vec!["test".to_string()]),
}
}
/// Helper function to create a quicklink
fn create_quicklink() -> Quicklink {
Quicklink {
link: QuicklinkLink {
components: vec![QuicklinkLinkComponent::StaticStr(
"https://example.com".to_string(),
)],
},
open_with: None,
}
}
/* test_check_main_extension_only */
#[test]
fn test_group_cannot_have_alias() {
let mut extension = create_basic_extension("test-group", ExtensionType::Group);
extension.alias = Some("group-alias".to_string());
let result = general_check(&extension);
assert!(result.is_err());
assert!(result.unwrap_err().contains("cannot have alias"));
}
#[test]
fn test_extension_cannot_have_alias() {
let mut extension = create_basic_extension("test-ext", ExtensionType::Extension);
extension.alias = Some("ext-alias".to_string());
let result = general_check(&extension);
assert!(result.is_err());
assert!(result.unwrap_err().contains("cannot have alias"));
}
#[test]
fn test_group_cannot_have_hotkey() {
let mut extension = create_basic_extension("test-group", ExtensionType::Group);
extension.hotkey = Some("cmd+g".to_string());
let result = general_check(&extension);
assert!(result.is_err());
assert!(result.unwrap_err().contains("cannot have hotkey"));
}
#[test]
fn test_extension_cannot_have_hotkey() {
let mut extension = create_basic_extension("test-ext", ExtensionType::Extension);
extension.hotkey = Some("cmd+e".to_string());
let result = general_check(&extension);
assert!(result.is_err());
assert!(result.unwrap_err().contains("cannot have hotkey"));
}
#[test]
fn test_non_container_types_cannot_have_sub_extensions() {
let mut extension = create_basic_extension("test-cmd", ExtensionType::Command);
extension.action = Some(create_command_action());
extension.commands = Some(vec![create_basic_extension(
"sub-cmd",
ExtensionType::Command,
)]);
let result = general_check(&extension);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.contains("only extension of type [Group] and [Extension] can have sub-extensions")
);
}
#[test]
fn test_non_searchable_extension_set_field_settings() {
let mut extension = create_basic_extension("test-group", ExtensionType::Group);
extension.settings = Some(ExtensionSettings {
hide_before_open: None,
});
let error_msg = general_check(&extension).unwrap_err();
assert!(
error_msg
.contains("field [settings] is currently only allowed in searchable extension")
);
let mut extension = create_basic_extension("test-extension", ExtensionType::Extension);
extension.settings = Some(ExtensionSettings {
hide_before_open: None,
});
let error_msg = general_check(&extension).unwrap_err();
assert!(
error_msg
.contains("field [settings] is currently only allowed in searchable extension")
);
}
/* test_check_main_extension_only */
/* test check_main_extension_or_sub_extension */
#[test]
fn test_command_must_have_action() {
let extension = create_basic_extension("test-cmd", ExtensionType::Command);
let result = general_check(&extension);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.contains("field [action] should be set for a Command extension")
);
}
#[test]
fn test_non_command_cannot_have_action() {
let mut extension = create_basic_extension("test-script", ExtensionType::Script);
extension.action = Some(create_command_action());
let result = general_check(&extension);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.contains("field [action] is set for a non-Command extension")
);
}
#[test]
fn test_quicklink_must_have_quicklink_field() {
let extension = create_basic_extension("test-quicklink", ExtensionType::Quicklink);
let result = general_check(&extension);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.contains("field [quicklink] should be set for a Quicklink extension")
);
}
#[test]
fn test_non_quicklink_cannot_have_quicklink_field() {
let mut extension = create_basic_extension("test-cmd", ExtensionType::Command);
extension.action = Some(create_command_action());
extension.quicklink = Some(create_quicklink());
let result = general_check(&extension);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.contains("field [quicklink] is set for a non-Quicklink extension")
);
}
#[test]
fn test_view_must_have_page_field() {
let mut extension = create_basic_extension("test-view", ExtensionType::View);
// create_basic_extension() will set its page field if type is View, clear it
extension.page = None;
let result = general_check(&extension);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.contains("field [page] should be set for a View extension")
);
}
#[test]
fn test_non_view_cannot_have_page_field() {
let mut extension = create_basic_extension("test-cmd", ExtensionType::Command);
extension.action = Some(create_command_action());
extension.page = Some("index.html".into());
let result = general_check(&extension);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.contains("field [page] is set for a non-View extension")
);
}
/* test check_main_extension_or_sub_extension */
/* Test check_sub_extension_only */
#[test]
fn test_sub_extension_cannot_be_group() {
let mut extension = create_basic_extension("test-group", ExtensionType::Group);
let sub_group = create_basic_extension("sub-group", ExtensionType::Group);
extension.commands = Some(vec![sub_group]);
let result = general_check(&extension);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.contains("sub-extensions should not be of type [Group] or [Extension]")
);
}
#[test]
fn test_sub_extension_cannot_be_extension() {
let mut extension = create_basic_extension("test-ext", ExtensionType::Extension);
let sub_ext = create_basic_extension("sub-ext", ExtensionType::Extension);
extension.scripts = Some(vec![sub_ext]);
let result = general_check(&extension);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.contains("sub-extensions should not be of type [Group] or [Extension]")
);
}
#[test]
fn test_sub_extension_cannot_have_developer() {
let mut extension = create_basic_extension("test-group", ExtensionType::Group);
let mut sub_cmd = create_basic_extension("sub-cmd", ExtensionType::Command);
sub_cmd.action = Some(create_command_action());
sub_cmd.developer = Some("test-dev".to_string());
extension.commands = Some(vec![sub_cmd]);
let result = general_check(&extension);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.contains("field [developer] should not be set in sub-extensions")
);
}
#[test]
fn test_sub_extension_cannot_have_sub_extensions() {
let mut extension = create_basic_extension("test-group", ExtensionType::Group);
let mut sub_cmd = create_basic_extension("sub-cmd", ExtensionType::Command);
sub_cmd.action = Some(create_command_action());
sub_cmd.commands = Some(vec![create_basic_extension(
"nested-cmd",
ExtensionType::Command,
)]);
extension.commands = Some(vec![sub_cmd]);
let result = general_check(&extension);
assert!(result.is_err());
assert!(result.unwrap_err().contains(
"fields [commands/scripts/quicklinks/views] should not be set in sub-extensions"
));
}
/* Test check_sub_extension_only */
#[test]
fn test_duplicate_sub_extension_ids() {
let mut extension = create_basic_extension("test-group", ExtensionType::Group);
let mut cmd1 = create_basic_extension("duplicate-id", ExtensionType::Command);
cmd1.action = Some(create_command_action());
let mut cmd2 = create_basic_extension("duplicate-id", ExtensionType::Command);
cmd2.action = Some(create_command_action());
extension.commands = Some(vec![cmd1, cmd2]);
let result = general_check(&extension);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.contains("sub-extension with ID [duplicate-id] already exists")
);
}
#[test]
fn test_duplicate_ids_across_different_sub_extension_types() {
let mut extension = create_basic_extension("test-group", ExtensionType::Group);
let mut cmd = create_basic_extension("same-id", ExtensionType::Command);
cmd.action = Some(create_command_action());
let script = create_basic_extension("same-id", ExtensionType::Script);
extension.commands = Some(vec![cmd]);
extension.scripts = Some(vec![script]);
let result = general_check(&extension);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.contains("sub-extension with ID [same-id] already exists")
);
}
#[test]
fn test_valid_group_extension() {
let mut extension = create_basic_extension("test-group", ExtensionType::Group);
extension.commands = Some(vec![create_basic_extension("cmd1", ExtensionType::Command)]);
assert!(general_check(&extension).is_ok());
}
#[test]
fn test_valid_extension_type() {
let mut extension = create_basic_extension("test-ext", ExtensionType::Extension);
extension.scripts = Some(vec![create_basic_extension(
"script1",
ExtensionType::Script,
)]);
assert!(general_check(&extension).is_ok());
}
#[test]
fn test_valid_command_extension() {
let mut extension = create_basic_extension("test-cmd", ExtensionType::Command);
extension.action = Some(create_command_action());
assert!(general_check(&extension).is_ok());
}
#[test]
fn test_valid_quicklink_extension() {
let mut extension = create_basic_extension("test-quicklink", ExtensionType::Quicklink);
extension.quicklink = Some(create_quicklink());
assert!(general_check(&extension).is_ok());
}
#[test]
fn test_valid_complex_extension() {
let mut extension = create_basic_extension("spotify-controls", ExtensionType::Extension);
// Add valid commands
let mut play_pause = create_basic_extension("play-pause", ExtensionType::Command);
play_pause.action = Some(create_command_action());
let mut next_track = create_basic_extension("next-track", ExtensionType::Command);
next_track.action = Some(create_command_action());
let mut prev_track = create_basic_extension("prev-track", ExtensionType::Command);
prev_track.action = Some(create_command_action());
extension.commands = Some(vec![play_pause, next_track, prev_track]);
assert!(general_check(&extension).is_ok());
}
#[test]
fn test_valid_single_layer_command() {
let mut extension = create_basic_extension("empty-trash", ExtensionType::Command);
extension.action = Some(create_command_action());
assert!(general_check(&extension).is_ok());
}
#[test]
fn test_command_alias_and_hotkey_allowed() {
let mut extension = create_basic_extension("test-cmd", ExtensionType::Command);
extension.action = Some(create_command_action());
extension.alias = Some("cmd-alias".to_string());
extension.hotkey = Some("cmd+t".to_string());
assert!(general_check(&extension).is_ok());
}
/*
* Tests for check that sub extension cannot support extensions that are not
* supported by the main extension
*
* Start here
*/
#[test]
fn test_platform_validation_both_none() {
// Case 1: main extension's platforms = None, sub extension's platforms = None
// Should return Ok(())
let mut main_extension = create_basic_extension("main-ext", ExtensionType::Group);
main_extension.platforms = None;
let mut sub_cmd = create_basic_extension("sub-cmd", ExtensionType::Command);
sub_cmd.action = Some(create_command_action());
sub_cmd.platforms = None;
main_extension.commands = Some(vec![sub_cmd]);
let result = general_check(&main_extension);
assert!(result.is_ok());
}
#[test]
fn test_platform_validation_main_all_sub_none() {
// Case 2: main extension's platforms = Some(all platforms), sub extension's platforms = None
// Should return Ok(())
let mut main_extension = create_basic_extension("main-ext", ExtensionType::Group);
main_extension.platforms = Some(Platform::all());
let mut sub_cmd = create_basic_extension("sub-cmd", ExtensionType::Command);
sub_cmd.action = Some(create_command_action());
sub_cmd.platforms = None;
main_extension.commands = Some(vec![sub_cmd]);
let result = general_check(&main_extension);
assert!(result.is_ok());
}
#[test]
fn test_platform_validation_main_none_sub_some() {
// Case 3: main extension's platforms = None, sub extension's platforms = Some([Platform::Macos])
// Should return Ok(()) because None means supports all platforms
let mut main_extension = create_basic_extension("main-ext", ExtensionType::Group);
main_extension.platforms = None;
let mut sub_cmd = create_basic_extension("sub-cmd", ExtensionType::Command);
sub_cmd.action = Some(create_command_action());
sub_cmd.platforms = Some(HashSet::from([Platform::Macos]));
main_extension.commands = Some(vec![sub_cmd]);
let result = general_check(&main_extension);
assert!(result.is_ok());
}
#[test]
fn test_platform_validation_main_all_sub_subset() {
// Case 4: main extension's platforms = Some(all platforms), sub extension's platforms = Some([Platform::Macos])
// Should return Ok(()) because sub extension supports a subset of main extension's platforms
let mut main_extension = create_basic_extension("main-ext", ExtensionType::Group);
main_extension.platforms = Some(Platform::all());
let mut sub_cmd = create_basic_extension("sub-cmd", ExtensionType::Command);
sub_cmd.action = Some(create_command_action());
sub_cmd.platforms = Some(HashSet::from([Platform::Macos]));
main_extension.commands = Some(vec![sub_cmd]);
let result = general_check(&main_extension);
assert!(result.is_ok());
}
#[test]
fn test_platform_validation_main_limited_sub_unsupported() {
// Case 5: main extension's platforms = Some([Platform::Macos]), sub extension's platforms = Some([Platform::Linux])
// Should return Err because sub extension supports a platform not supported by main extension
let mut main_extension = create_basic_extension("main-ext", ExtensionType::Group);
main_extension.platforms = Some(HashSet::from([Platform::Macos]));
let mut sub_cmd = create_basic_extension("sub-cmd", ExtensionType::Command);
sub_cmd.action = Some(create_command_action());
sub_cmd.platforms = Some(HashSet::from([Platform::Linux]));
main_extension.commands = Some(vec![sub_cmd]);
let result = general_check(&main_extension);
assert!(result.is_err());
let error_msg = result.unwrap_err();
assert!(error_msg.contains("it supports platforms"));
assert!(error_msg.contains("that are not supported by the main extension"));
assert!(error_msg.contains("Linux")); // Should mention the unsupported platform
}
#[test]
fn test_platform_validation_main_partial_sub_unsupported() {
// Case 6: main extension's platforms = Some([Platform::Macos, Platform::Windows]), sub extension's platforms = Some([Platform::Linux])
// Should return Err because sub extension supports a platform not supported by main extension
let mut main_extension = create_basic_extension("main-ext", ExtensionType::Group);
main_extension.platforms = Some(HashSet::from([Platform::Macos, Platform::Windows]));
let mut sub_cmd = create_basic_extension("sub-cmd", ExtensionType::Command);
sub_cmd.action = Some(create_command_action());
sub_cmd.platforms = Some(HashSet::from([Platform::Linux]));
main_extension.commands = Some(vec![sub_cmd]);
let result = general_check(&main_extension);
assert!(result.is_err());
let error_msg = result.unwrap_err();
assert!(error_msg.contains("it supports platforms"));
assert!(error_msg.contains("that are not supported by the main extension"));
assert!(error_msg.contains("Linux")); // Should mention the unsupported platform
}
#[test]
fn test_platform_validation_main_limited_sub_none() {
// Case 7: main extension's platforms = Some([Platform::Macos]), sub extension's platforms = None
// Should return Ok(()) because when sub extension's platforms is None, it inherits main extension's platforms
let mut main_extension = create_basic_extension("main-ext", ExtensionType::Group);
main_extension.platforms = Some(HashSet::from([Platform::Macos]));
let mut sub_cmd = create_basic_extension("sub-cmd", ExtensionType::Command);
sub_cmd.action = Some(create_command_action());
sub_cmd.platforms = None;
main_extension.commands = Some(vec![sub_cmd]);
let result = general_check(&main_extension);
assert!(result.is_ok());
}
/*
* Tests for check that sub extension cannot support extensions that are not
* supported by the main extension
*
* End here
*/
}

View File

@@ -1,303 +0,0 @@
use crate::extension::third_party::check::general_check;
use crate::extension::third_party::install::{
convert_page, filter_out_incompatible_sub_extensions, is_extension_installed,
};
use crate::extension::third_party::{
THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE, get_third_party_extension_directory,
};
use crate::extension::{
Extension, canonicalize_relative_icon_path, canonicalize_relative_page_path,
};
use crate::extension::{ExtensionType, PLUGIN_JSON_FILE_NAME};
use crate::util::platform::Platform;
use serde_json::Value as Json;
use std::path::Path;
use std::path::PathBuf;
use tauri::AppHandle;
use tokio::fs;
/// All the extensions installed from local file will belong to a special developer
/// "__local__".
const DEVELOPER_ID_LOCAL: &str = "__local__";
/// Install the extension specified by `path`.
///
/// `path` should point to a directory with the following structure:
///
/// ```text
/// extension-directory/
/// ├── assets/
/// │ ├── icon.png
/// │ └── other-assets...
/// └── plugin.json
/// ```
#[tauri::command]
pub(crate) async fn install_local_extension(
tauri_app_handle: AppHandle,
path: PathBuf,
) -> Result<(), String> {
let extension_dir_name = path
.file_name()
.ok_or_else(|| "Invalid extension: no directory name".to_string())?
.to_str()
.ok_or_else(|| "Invalid extension: non-UTF8 extension id".to_string())?;
// we use extension directory name as the extension ID.
let extension_id = extension_dir_name;
if is_extension_installed(DEVELOPER_ID_LOCAL, extension_id).await {
// The frontend code uses this string to distinguish between 2 error cases:
//
// 1. This extension is already imported
// 2. This extension is incompatible with the current platform
// 3. The selected directory does not contain a valid extension
//
// do NOT edit this without updating the frontend code.
//
// ```ts
// if (errorMessage === "already imported") {
// addError(t("settings.extensions.hints.extensionAlreadyImported"));
// } else if (errorMessage === "incompatible") {
// addError(t("settings.extensions.hints.incompatibleExtension"));
// } else {
// addError(t("settings.extensions.hints.importFailed"));
// }
// ```
//
// This is definitely error-prone, but we have to do this until we have
// structured error type
return Err("already imported".into());
}
let plugin_json_path = path.join(PLUGIN_JSON_FILE_NAME);
let plugin_json_content = fs::read_to_string(&plugin_json_path)
.await
.map_err(|e| e.to_string())?;
// Parse as JSON first as it is not valid for `struct Extension`, we need to
// correct it (set fields `id` and `developer`) before converting it to `struct Extension`:
let mut extension_json: Json =
serde_json::from_str(&plugin_json_content).map_err(|e| e.to_string())?;
// Set the main extension ID to the directory name
let extension_obj = extension_json
.as_object_mut()
.expect("extension_json should be an object");
extension_obj.insert("id".to_string(), Json::String(extension_id.to_string()));
extension_obj.insert(
"developer".to_string(),
Json::String(DEVELOPER_ID_LOCAL.to_string()),
);
// Counter for sub-extension IDs
let mut counter = 1u32;
// Set IDs for commands
if let Some(commands) = extension_obj.get_mut("commands") {
if let Some(commands_array) = commands.as_array_mut() {
for command in commands_array {
if let Some(command_obj) = command.as_object_mut() {
command_obj.insert("id".to_string(), Json::String(counter.to_string()));
counter += 1;
}
}
}
}
// Set IDs for quicklinks
if let Some(quicklinks) = extension_obj.get_mut("quicklinks") {
if let Some(quicklinks_array) = quicklinks.as_array_mut() {
for quicklink in quicklinks_array {
if let Some(quicklink_obj) = quicklink.as_object_mut() {
quicklink_obj.insert("id".to_string(), Json::String(counter.to_string()));
counter += 1;
}
}
}
}
// Set IDs for scripts
if let Some(scripts) = extension_obj.get_mut("scripts") {
if let Some(scripts_array) = scripts.as_array_mut() {
for script in scripts_array {
if let Some(script_obj) = script.as_object_mut() {
script_obj.insert("id".to_string(), Json::String(counter.to_string()));
counter += 1;
}
}
}
}
// Now we can convert JSON to `struct Extension`
let mut extension: Extension =
serde_json::from_value(extension_json).map_err(|e| e.to_string())?;
let current_platform = Platform::current();
/* Check begins here */
general_check(&extension)?;
if let Some(ref platforms) = extension.platforms {
if !platforms.contains(&current_platform) {
// The frontend code uses this string to distinguish between 3 error cases:
//
// 1. This extension is already imported
// 2. This extension is incompatible with the current platform
// 3. The selected directory does not contain a valid extension
//
// do NOT edit this without updating the frontend code.
//
// ```ts
// if (errorMessage === "already imported") {
// addError(t("settings.extensions.hints.extensionAlreadyImported"));
// } else if (errorMessage === "incompatible") {
// addError(t("settings.extensions.hints.incompatibleExtension"));
// } else {
// addError(t("settings.extensions.hints.importFailed"));
// }
// ```
//
// This is definitely error-prone, but we have to do this until we have
// structured error type
return Err("incompatible".into());
}
}
/* Check ends here */
// Extension is compatible with current platform, but it could contain sub
// extensions that are not, filter them out.
filter_out_incompatible_sub_extensions(&mut extension, current_platform);
// We are going to modify our third-party extension list, grab the write lock
// to ensure exclusive access.
let mut third_party_ext_list_write_lock = THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE
.get()
.expect("global third party search source not set")
.write_lock()
.await;
// Create destination directory
let dest_dir = get_third_party_extension_directory(&tauri_app_handle)
.join(DEVELOPER_ID_LOCAL)
.join(extension_dir_name);
fs::create_dir_all(&dest_dir)
.await
.map_err(|e| e.to_string())?;
// Copy all files except plugin.json
let mut entries = fs::read_dir(&path).await.map_err(|e| e.to_string())?;
while let Some(entry) = entries.next_entry().await.map_err(|e| e.to_string())? {
let file_name = entry.file_name();
let file_name_str = file_name
.to_str()
.ok_or_else(|| "Invalid filename: non-UTF8".to_string())?;
// plugin.json will be handled separately.
if file_name_str == PLUGIN_JSON_FILE_NAME {
continue;
}
let src_path = entry.path();
let dest_path = dest_dir.join(&file_name);
if src_path.is_dir() {
// Recursively copy directory
copy_dir_recursively(&src_path, &dest_path).await?;
} else {
// Copy file
fs::copy(&src_path, &dest_path)
.await
.map_err(|e| e.to_string())?;
}
}
// Write the corrected plugin.json file
let corrected_plugin_json =
serde_json::to_string_pretty(&extension).map_err(|e| e.to_string())?;
let dest_plugin_json_path = dest_dir.join(PLUGIN_JSON_FILE_NAME);
fs::write(&dest_plugin_json_path, corrected_plugin_json)
.await
.map_err(|e| e.to_string())?;
/*
* Call convert_page() to update the page files. This has to be done after
* writing the extension files
*/
let absolute_page_paths: Vec<PathBuf> = {
fn canonicalize_page_path(page_path: &Path, extension_root: &Path) -> PathBuf {
if page_path.is_relative() {
// It is relative to the extension root directory
extension_root.join(page_path)
} else {
page_path.into()
}
}
if extension.r#type == ExtensionType::View {
let page = extension
.page
.as_ref()
.expect("View extension should set its page field");
let path = canonicalize_page_path(Path::new(page.as_str()), &dest_dir);
vec![path]
} else if extension.r#type.contains_sub_items()
&& let Some(ref views) = extension.views
{
let mut paths = Vec::with_capacity(views.len());
for view in views.iter() {
let page = view
.page
.as_ref()
.expect("View extension should set its page field");
let path = canonicalize_page_path(Path::new(page.as_str()), &dest_dir);
paths.push(path);
}
paths
} else {
// No pages in this extension
Vec::new()
}
};
for page_path in absolute_page_paths {
convert_page(&page_path).await?;
}
// Canonicalize relative icon and page paths
canonicalize_relative_icon_path(&dest_dir, &mut extension)?;
canonicalize_relative_page_path(&dest_dir, &mut extension)?;
// Add extension to the search source
third_party_ext_list_write_lock.push(extension);
Ok(())
}
/// Helper function to recursively copy directories.
#[async_recursion::async_recursion]
async fn copy_dir_recursively(src: &Path, dest: &Path) -> Result<(), String> {
tokio::fs::create_dir_all(dest)
.await
.map_err(|e| e.to_string())?;
let mut read_dir = tokio::fs::read_dir(src).await.map_err(|e| e.to_string())?;
while let Some(entry) = read_dir.next_entry().await.map_err(|e| e.to_string())? {
let src_path = entry.path();
let dest_path = dest.join(entry.file_name());
if src_path.is_dir() {
copy_dir_recursively(&src_path, &dest_path).await?;
} else {
tokio::fs::copy(&src_path, &dest_path)
.await
.map_err(|e| e.to_string())?;
}
}
Ok(())
}

View File

@@ -1,684 +0,0 @@
//! This module contains the code of extension installation.
//!
//!
//! # How
//!
//! Technically, installing an extension involves the following steps. The order
//! may vary between implementations.
//!
//! 1. Check if it is already installed, if so, return
//!
//! 2. Correct the `plugin.json` JSON if it does not conform to our `struct
//! Extension` definition. This can happen because the JSON written by
//! developers is in a simplified form for a better developer experience.
//!
//! 3. Validate the corrected `plugin.json`
//! 1. misc checks
//! 2. Platform compatibility check
//!
//! 4. Write the extension files to the corresponding location
//!
//! * developer directory
//! * extension directory
//! * assets directory
//! * various assets files, e.g., "icon.png"
//! * plugin.json file
//! * View pages if exist
//!
//! 5. If this extension contains any View extensions, call `convert_page()`
//! on them to make them loadable by Tauri/webview.
//!
//! See `convert_page()` for more info.
//!
//! 6. Canonicalize `Extension.icon` and `Extension.page` fields if they are
//! relative paths
//!
//! * icon: relative to the `assets` directory
//! * page: relative to the extension root directory
//!
//! 7. Add the extension to the in-memory extension list.
pub(crate) mod local_extension;
pub(crate) mod store;
use crate::extension::Extension;
use crate::util::platform::Platform;
use std::path::Path;
use super::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE;
pub(crate) async fn is_extension_installed(developer: &str, extension_id: &str) -> bool {
THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE
.get()
.expect("global third party search source not set")
.extension_exists(developer, extension_id)
.await
}
/// Filters out sub-extensions that are not compatible with the current platform.
///
/// We make `current_platform` an argument so that this function is testable.
pub(crate) fn filter_out_incompatible_sub_extensions(
extension: &mut Extension,
current_platform: Platform,
) {
// Only process extensions of type Group or Extension that can have sub-extensions
if !extension.r#type.contains_sub_items() {
return;
}
// For main extensions, None means all.
let main_extension_supported_platforms = extension.platforms.clone().unwrap_or(Platform::all());
// Filter commands
if let Some(ref mut commands) = extension.commands {
commands.retain(|sub_ext| {
if let Some(ref platforms) = sub_ext.platforms {
platforms.contains(&current_platform)
} else {
main_extension_supported_platforms.contains(&current_platform)
}
});
}
// Filter scripts
if let Some(ref mut scripts) = extension.scripts {
scripts.retain(|sub_ext| {
if let Some(ref platforms) = sub_ext.platforms {
platforms.contains(&current_platform)
} else {
main_extension_supported_platforms.contains(&current_platform)
}
});
}
// Filter quicklinks
if let Some(ref mut quicklinks) = extension.quicklinks {
quicklinks.retain(|sub_ext| {
if let Some(ref platforms) = sub_ext.platforms {
platforms.contains(&current_platform)
} else {
main_extension_supported_platforms.contains(&current_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(&current_platform)
} else {
main_extension_supported_platforms.contains(&current_platform)
}
});
}
}
/// Convert the page file to make it loadable by the Tauri/Webview.
pub(crate) async fn convert_page(absolute_page_path: &Path) -> Result<(), String> {
assert!(absolute_page_path.is_absolute());
let page_content = tokio::fs::read_to_string(absolute_page_path)
.await
.map_err(|e| e.to_string())?;
let new_page_content = _convert_page(&page_content, absolute_page_path)?;
// overwrite it
tokio::fs::write(absolute_page_path, new_page_content)
.await
.map_err(|e| e.to_string())?;
Ok(())
}
/// NOTE: There is no Rust implementation of `convertFileSrc()` in Tauri. Our
/// impl here is based on [comment](https://github.com/tauri-apps/tauri/issues/12022#issuecomment-2572879115)
fn convert_file_src(path: &Path) -> Result<String, String> {
#[cfg(any(windows, target_os = "android"))]
let base = "http://asset.localhost/";
#[cfg(not(any(windows, target_os = "android")))]
let base = "asset://localhost/";
let path =
dunce::canonicalize(path).map_err(|e| format!("Failed to canonicalize path: {}", e))?;
let path_str = path.to_string_lossy();
let encoded = urlencoding::encode(&path_str);
Ok(format!("{base}{encoded}"))
}
/// Tauri cannot directly access the file system, to make a file loadable, we
/// have to `canonicalize()` and `convertFileSrc()` its path before passing it
/// to Tauri.
///
/// View extension's page is a HTML file that Coco (Tauri) will load, we need
/// to process all `<PATH>` tags:
///
/// 1. `<script type="xxx" crossorigin src="<PATH>"></script>`
/// 2. `<a href="<PATH>">xxx</a>`
/// 3. `<link rel="xxx" href="<PATH>"/>`
/// 4. `<img class="xxx" src="<PATH>" alt="xxx"/>`
fn _convert_page(page_content: &str, absolute_page_path: &Path) -> Result<String, String> {
use scraper::{Html, Selector};
/// Helper function.
///
/// Search `document` for the tag attributes specified by `tag_with_attribute`
/// and `tag_attribute`, call `convert_file_src()`, then update the attribute
/// value with the function return value.
fn modify_tag_attributes(
document: &Html,
modified_html: &mut String,
base_dir: &Path,
tag_with_attribute: &str,
tag_attribute: &str,
) -> Result<(), String> {
let script_selector = Selector::parse(tag_with_attribute).unwrap();
for element in document.select(&script_selector) {
if let Some(src) = element.value().attr(tag_attribute) {
if !src.starts_with("http://")
&& !src.starts_with("https://")
&& !src.starts_with("asset://")
&& !src.starts_with("http://asset.localhost/")
{
// It could be a path like "/assets/index-41be3ec9.js", but it
// is still a relative path. We need to remove the starting /
// or path.join() will think it is an absolute path and does nothing
let corrected_src = if src.starts_with('/') { &src[1..] } else { src };
let full_path = base_dir.join(corrected_src);
let converted_path = convert_file_src(full_path.as_path())?;
*modified_html = modified_html.replace(
&format!("{}=\"{}\"", tag_attribute, src),
&format!("{}=\"{}\"", tag_attribute, converted_path),
);
}
}
}
Ok(())
}
let base_dir = absolute_page_path
.parent()
.ok_or_else(|| format!("page path is invalid, it should have a parent path"))?;
let document: Html = Html::parse_document(page_content);
let mut modified_html: String = page_content.to_string();
modify_tag_attributes(
&document,
&mut modified_html,
base_dir,
"script[src]",
"src",
)?;
modify_tag_attributes(&document, &mut modified_html, base_dir, "a[href]", "href")?;
modify_tag_attributes(
&document,
&mut modified_html,
base_dir,
"link[href]",
"href",
)?;
modify_tag_attributes(&document, &mut modified_html, base_dir, "img[src]", "src")?;
Ok(modified_html)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::extension::ExtensionType;
use std::collections::HashSet;
/// Helper function to create a basic extension for testing
/// `filter_out_incompatible_sub_extensions`
fn create_test_extension(
extension_type: ExtensionType,
platforms: Option<HashSet<Platform>>,
) -> Extension {
Extension {
id: "ID".into(),
name: "name".into(),
developer: None,
platforms,
description: "Test extension".to_string(),
icon: "test-icon".to_string(),
r#type: extension_type,
action: None,
quicklink: None,
commands: None,
scripts: None,
quicklinks: None,
views: None,
alias: None,
hotkey: None,
enabled: true,
settings: None,
page: None,
permission: None,
screenshots: None,
url: None,
version: None,
}
}
#[test]
fn test_filter_out_incompatible_sub_extensions_filter_non_group_extension_unchanged() {
// Command
let mut extension = create_test_extension(ExtensionType::Command, None);
let clone = extension.clone();
filter_out_incompatible_sub_extensions(&mut extension, Platform::Linux);
assert_eq!(extension, clone);
// Quicklink
let mut extension = create_test_extension(ExtensionType::Quicklink, None);
let clone = extension.clone();
filter_out_incompatible_sub_extensions(&mut extension, Platform::Linux);
assert_eq!(extension, clone);
}
#[test]
fn test_filter_out_incompatible_sub_extensions() {
let mut main_extension = create_test_extension(ExtensionType::Group, None);
// init sub extensions, which are macOS-only
let commands = vec![create_test_extension(
ExtensionType::Command,
Some(HashSet::from([Platform::Macos])),
)];
let quicklinks = vec![create_test_extension(
ExtensionType::Quicklink,
Some(HashSet::from([Platform::Macos])),
)];
let scripts = vec![create_test_extension(
ExtensionType::Script,
Some(HashSet::from([Platform::Macos])),
)];
let views = vec![create_test_extension(
ExtensionType::View,
Some(HashSet::from([Platform::Macos])),
)];
// Set sub extensions
main_extension.commands = Some(commands);
main_extension.quicklinks = Some(quicklinks);
main_extension.scripts = Some(scripts);
main_extension.views = Some(views);
// Current platform is Linux, all the sub extensions should be filtered out.
filter_out_incompatible_sub_extensions(&mut main_extension, Platform::Linux);
// assertions
assert!(main_extension.commands.unwrap().is_empty());
assert!(main_extension.quicklinks.unwrap().is_empty());
assert!(main_extension.scripts.unwrap().is_empty());
assert!(main_extension.views.unwrap().is_empty());
}
/// Sub extensions are compatible with all the platforms, nothing to filter out.
#[test]
fn test_filter_out_incompatible_sub_extensions_all_compatible() {
{
let mut main_extension = create_test_extension(ExtensionType::Group, None);
// init sub extensions, which are compatible with all the platforms
let commands = vec![create_test_extension(
ExtensionType::Command,
Some(Platform::all()),
)];
let quicklinks = vec![create_test_extension(
ExtensionType::Quicklink,
Some(Platform::all()),
)];
let scripts = vec![create_test_extension(
ExtensionType::Script,
Some(Platform::all()),
)];
let views = vec![create_test_extension(
ExtensionType::View,
Some(Platform::all()),
)];
// Set sub extensions
main_extension.commands = Some(commands);
main_extension.quicklinks = Some(quicklinks);
main_extension.scripts = Some(scripts);
main_extension.views = Some(views);
// Current platform is Linux, all the sub extensions should be filtered out.
filter_out_incompatible_sub_extensions(&mut main_extension, Platform::Linux);
// assertions
assert_eq!(main_extension.commands.unwrap().len(), 1);
assert_eq!(main_extension.quicklinks.unwrap().len(), 1);
assert_eq!(main_extension.scripts.unwrap().len(), 1);
assert_eq!(main_extension.views.unwrap().len(), 1);
}
// main extension is compatible with all platforms, sub extension's platforms
// is None, which means all platforms are supported
{
let mut main_extension = create_test_extension(ExtensionType::Group, None);
// init sub extensions, which are compatible with all the platforms
let commands = vec![create_test_extension(ExtensionType::Command, None)];
let quicklinks = vec![create_test_extension(ExtensionType::Quicklink, None)];
let scripts = vec![create_test_extension(ExtensionType::Script, None)];
let views = vec![create_test_extension(ExtensionType::View, None)];
// Set sub extensions
main_extension.commands = Some(commands);
main_extension.quicklinks = Some(quicklinks);
main_extension.scripts = Some(scripts);
main_extension.views = Some(views);
// Current platform is Linux, all the sub extensions should be filtered out.
filter_out_incompatible_sub_extensions(&mut main_extension, Platform::Linux);
// assertions
assert_eq!(main_extension.commands.unwrap().len(), 1);
assert_eq!(main_extension.quicklinks.unwrap().len(), 1);
assert_eq!(main_extension.scripts.unwrap().len(), 1);
assert_eq!(main_extension.views.unwrap().len(), 1);
}
}
#[test]
fn test_main_extension_is_incompatible_sub_extension_platforms_none() {
{
let mut main_extension =
create_test_extension(ExtensionType::Group, Some(HashSet::from([Platform::Macos])));
let commands = vec![create_test_extension(ExtensionType::Command, None)];
main_extension.commands = Some(commands);
filter_out_incompatible_sub_extensions(&mut main_extension, Platform::Linux);
assert_eq!(main_extension.commands.unwrap().len(), 0);
}
{
let mut main_extension =
create_test_extension(ExtensionType::Group, Some(HashSet::from([Platform::Macos])));
let scripts = vec![create_test_extension(ExtensionType::Script, None)];
main_extension.scripts = Some(scripts);
filter_out_incompatible_sub_extensions(&mut main_extension, Platform::Linux);
assert_eq!(main_extension.scripts.unwrap().len(), 0);
}
{
let mut main_extension =
create_test_extension(ExtensionType::Group, Some(HashSet::from([Platform::Macos])));
let quicklinks = vec![create_test_extension(ExtensionType::Quicklink, None)];
main_extension.quicklinks = Some(quicklinks);
filter_out_incompatible_sub_extensions(&mut main_extension, Platform::Linux);
assert_eq!(main_extension.quicklinks.unwrap().len(), 0);
}
{
let mut main_extension =
create_test_extension(ExtensionType::Group, Some(HashSet::from([Platform::Macos])));
let views = vec![create_test_extension(ExtensionType::View, None)];
main_extension.views = Some(views);
filter_out_incompatible_sub_extensions(&mut main_extension, Platform::Linux);
assert_eq!(main_extension.views.unwrap().len(), 0);
}
}
#[test]
fn test_main_extension_compatible_sub_extension_platforms_none() {
let mut main_extension =
create_test_extension(ExtensionType::Group, Some(HashSet::from([Platform::Macos])));
let views = vec![create_test_extension(ExtensionType::View, None)];
main_extension.views = Some(views);
filter_out_incompatible_sub_extensions(&mut main_extension, Platform::Macos);
assert_eq!(main_extension.views.unwrap().len(), 1);
}
#[test]
fn test_convert_page_script_tag() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let html_file = temp_dir.path().join("test.html");
let js_file = temp_dir.path().join("main.js");
let html_content = r#"<html><body><script src="main.js"></script></body></html>"#;
std::fs::write(&html_file, html_content).unwrap();
std::fs::write(&js_file, "").unwrap();
let result = _convert_page(html_content, &html_file).unwrap();
let path = convert_file_src(&js_file).unwrap();
let expected = format!(
"<html><body><script src=\"{}\"></script></body></html>",
path
);
assert_eq!(result, expected);
}
#[test]
fn test_convert_page_script_tag_with_a_root_char() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let html_file = temp_dir.path().join("test.html");
let js_file = temp_dir.path().join("main.js");
let html_content = r#"<html><body><script src="/main.js"></script></body></html>"#;
std::fs::write(&html_file, html_content).unwrap();
std::fs::write(&js_file, "").unwrap();
let result = _convert_page(html_content, &html_file).unwrap();
let path = convert_file_src(&js_file).unwrap();
let expected = format!(
"<html><body><script src=\"{}\"></script></body></html>",
path
);
assert_eq!(result, expected);
}
#[test]
fn test_convert_page_a_tag() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let html_file = temp_dir.path().join("test.html");
let js_file = temp_dir.path().join("main.js");
let html_content = r#"<html><body><a href="main.js">foo</a></body></html>"#;
std::fs::write(&html_file, html_content).unwrap();
std::fs::write(&js_file, "").unwrap();
let result = _convert_page(html_content, &html_file).unwrap();
let path = convert_file_src(&js_file).unwrap();
let expected = format!("<html><body><a href=\"{}\">foo</a></body></html>", path);
assert_eq!(result, expected);
}
#[test]
fn test_convert_page_a_tag_with_a_root_char() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let html_file = temp_dir.path().join("test.html");
let js_file = temp_dir.path().join("main.js");
let html_content = r#"<html><body><a href="/main.js">foo</a></body></html>"#;
std::fs::write(&html_file, html_content).unwrap();
std::fs::write(&js_file, "").unwrap();
let result = _convert_page(html_content, &html_file).unwrap();
let path = convert_file_src(&js_file).unwrap();
let expected = format!("<html><body><a href=\"{}\">foo</a></body></html>", path);
assert_eq!(result, expected);
}
#[test]
fn test_convert_page_link_href_tag() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let html_file = temp_dir.path().join("test.html");
let css_file = temp_dir.path().join("main.css");
let html_content = r#"<html><body><link rel="stylesheet" href="main.css"/></body></html>"#;
std::fs::write(&html_file, html_content).unwrap();
std::fs::write(&css_file, "").unwrap();
let result = _convert_page(html_content, &html_file).unwrap();
let path = convert_file_src(&css_file).unwrap();
let expected = format!(
"<html><body><link rel=\"stylesheet\" href=\"{}\"/></body></html>",
path
);
assert_eq!(result, expected);
}
#[test]
fn test_convert_page_link_href_tag_with_a_root_tag() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let html_file = temp_dir.path().join("test.html");
let css_file = temp_dir.path().join("main.css");
let html_content = r#"<html><body><link rel="stylesheet" href="/main.css"/></body></html>"#;
std::fs::write(&html_file, html_content).unwrap();
std::fs::write(&css_file, "").unwrap();
let result = _convert_page(html_content, &html_file).unwrap();
let path = convert_file_src(&css_file).unwrap();
let expected = format!(
"<html><body><link rel=\"stylesheet\" href=\"{}\"/></body></html>",
path
);
assert_eq!(result, expected);
}
#[test]
fn test_convert_page_img_src_tag() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let html_file = temp_dir.path().join("test.html");
let png_file = temp_dir.path().join("main.png");
let html_content =
r#"<html><body> <img class="fit-picture" src="main.png" alt="xxx" /></body></html>"#;
std::fs::write(&html_file, html_content).unwrap();
std::fs::write(&png_file, "").unwrap();
let result = _convert_page(html_content, &html_file).unwrap();
let path = convert_file_src(&png_file).unwrap();
let expected = format!(
"<html><body> <img class=\"fit-picture\" src=\"{}\" alt=\"xxx\" /></body></html>",
path
);
assert_eq!(result, expected);
}
#[test]
fn test_convert_page_img_src_tag_with_a_root_tag() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let html_file = temp_dir.path().join("test.html");
let png_file = temp_dir.path().join("main.png");
let html_content =
r#"<html><body> <img class="fit-picture" src="/main.png" alt="xxx" /></body></html>"#;
std::fs::write(&html_file, html_content).unwrap();
std::fs::write(&png_file, "").unwrap();
let result = _convert_page(html_content, &html_file).unwrap();
let path = convert_file_src(&png_file).unwrap();
let expected = format!(
"<html><body> <img class=\"fit-picture\" src=\"{}\" alt=\"xxx\" /></body></html>",
path
);
assert_eq!(result, expected);
}
#[test]
fn test_convert_page_contain_both_script_and_a_tags() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let html_file = temp_dir.path().join("test.html");
let js_file = temp_dir.path().join("main.js");
let html_content =
r#"<html><body><a href="main.js">foo</a><script src="main.js"></script></body></html>"#;
std::fs::write(&html_file, html_content).unwrap();
std::fs::write(&js_file, "").unwrap();
let result = _convert_page(html_content, &html_file).unwrap();
let path = convert_file_src(&js_file).unwrap();
let expected = format!(
"<html><body><a href=\"{}\">foo</a><script src=\"{}\"></script></body></html>",
path, path
);
assert_eq!(result, expected);
}
#[test]
fn test_convert_page_contain_both_script_and_a_tags_with_root_char() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let html_file = temp_dir.path().join("test.html");
let js_file = temp_dir.path().join("main.js");
let html_content = r#"<html><body><a href="/main.js">foo</a><script src="/main.js"></script></body></html>"#;
std::fs::write(&html_file, html_content).unwrap();
std::fs::write(&js_file, "").unwrap();
let result = _convert_page(html_content, &html_file).unwrap();
let path = convert_file_src(&js_file).unwrap();
let expected = format!(
"<html><body><a href=\"{}\">foo</a><script src=\"{}\"></script></body></html>",
path, path
);
assert_eq!(result, expected);
}
#[test]
fn test_convert_page_empty_html() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let html_file = temp_dir.path().join("test.html");
let html_content = "";
std::fs::write(&html_file, html_content).unwrap();
let result = _convert_page(html_content, &html_file).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_convert_page_only_html_tag() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let html_file = temp_dir.path().join("test.html");
let html_content = "<html></html>";
std::fs::write(&html_file, html_content).unwrap();
let result = _convert_page(html_content, &html_file).unwrap();
assert_eq!(result, html_content);
}
}

View File

@@ -1,7 +1,7 @@
pub(crate) mod check;
pub(crate) mod install;
pub(crate) mod store;
use super::Extension;
use super::ExtensionType;
use super::LOCAL_QUERY_SOURCE_TYPE;
use super::PLUGIN_JSON_FILE_NAME;
use super::alter_extension_json_file;
@@ -15,20 +15,18 @@ use crate::common::search::QuerySource;
use crate::common::search::SearchQuery;
use crate::common::traits::SearchSource;
use crate::extension::ExtensionBundleIdBorrowed;
use crate::extension::calculate_text_similarity;
use crate::extension::canonicalize_relative_page_path;
use crate::util::platform::Platform;
use async_trait::async_trait;
use borrowme::ToOwned;
use check::general_check;
use function_name::named;
use std::io::ErrorKind;
use std::ffi::OsStr;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::OnceLock;
use tauri::AppHandle;
use tauri::Manager;
use tauri::Runtime;
use tauri::async_runtime;
use tauri_plugin_global_shortcut::GlobalShortcutExt;
use tauri_plugin_global_shortcut::ShortcutState;
@@ -36,7 +34,9 @@ use tokio::fs::read_dir;
use tokio::sync::RwLock;
use tokio::sync::RwLockWriteGuard;
pub(crate) fn get_third_party_extension_directory(tauri_app_handle: &AppHandle) -> PathBuf {
pub(crate) fn get_third_party_extension_directory<R: Runtime>(
tauri_app_handle: &AppHandle<R>,
) -> PathBuf {
let mut app_data_dir = tauri_app_handle.path().app_data_dir().expect(
"User home directory not found, which should be impossible on desktop environments",
);
@@ -45,9 +45,11 @@ pub(crate) fn get_third_party_extension_directory(tauri_app_handle: &AppHandle)
app_data_dir
}
pub(crate) async fn load_third_party_extensions_from_directory(
pub(crate) async fn list_third_party_extensions(
directory: &Path,
) -> Result<Vec<Extension>, String> {
) -> Result<(bool, Vec<Extension>), String> {
let mut found_invalid_extensions = false;
let mut extensions_dir_iter = read_dir(&directory).await.map_err(|e| e.to_string())?;
let current_platform = Platform::current();
@@ -63,6 +65,7 @@ pub(crate) async fn load_third_party_extensions_from_directory(
};
let developer_dir_file_type = developer_dir.file_type().await.map_err(|e| e.to_string())?;
if !developer_dir_file_type.is_dir() {
found_invalid_extensions = true;
log::warn!(
"file [{}] under the third party extension directory should be a directory, but it is not",
developer_dir.file_name().display()
@@ -84,17 +87,14 @@ pub(crate) async fn load_third_party_extensions_from_directory(
let Some(extension_dir) = opt_extension_dir else {
break 'extension;
};
let extension_dir_file_name = extension_dir
.file_name()
.into_string()
.expect("extension directory name should be UTF-8 encoded");
let extension_dir_file_type =
extension_dir.file_type().await.map_err(|e| e.to_string())?;
if !extension_dir_file_type.is_dir() {
found_invalid_extensions = true;
log::warn!(
"invalid extension [{}]: a valid extension should be a directory, but it is not",
extension_dir_file_name
extension_dir.file_name().display()
);
// Skip invalid extension
@@ -109,6 +109,7 @@ pub(crate) async fn load_third_party_extensions_from_directory(
};
if !plugin_json_file_path.is_file() {
found_invalid_extensions = true;
log::warn!(
"invalid extension: [{}]: extension file [{}] should be a JSON file, but it is not",
extension_dir.file_name().display(),
@@ -125,9 +126,10 @@ pub(crate) async fn load_third_party_extensions_from_directory(
let mut extension = match serde_json::from_str::<Extension>(&plugin_json_file_content) {
Ok(extension) => extension,
Err(e) => {
found_invalid_extensions = true;
log::warn!(
"invalid extension: [{}]: cannot parse file [{}] as a [struct Extension], error: '{}'",
extension_dir_file_name,
"invalid extension: [{}]: extension file [{}] is invalid, error: '{}'",
extension_dir.file_name().display(),
plugin_json_file_path.display(),
e
);
@@ -135,57 +137,20 @@ pub(crate) async fn load_third_party_extensions_from_directory(
}
};
/* Check starts here */
if extension.id != extension_dir_file_name {
log::warn!(
"extension under [{}:{}] has an ID that is not same as the [{}]",
developer_dir.file_name().display(),
extension_dir_file_name,
extension.id,
);
continue;
}
// Extension should be unique
if extensions.iter().any(|ext: &Extension| {
ext.id == extension.id && ext.developer == extension.developer
}) {
log::warn!(
"an extension with the same bundle ID [ID {}, developer {:?}] already exists, skip this one",
extension.id,
extension.developer
);
continue;
}
if let Err(error_msg) = general_check(&extension) {
log::warn!("{}", error_msg);
// Turn it into an absolute path if it is a valid relative path because frontend code need this.
canonicalize_relative_icon_path(&extension_dir.path(), &mut extension)?;
if !validate_extension(
&extension,
&extension_dir.file_name(),
&extensions,
current_platform,
) {
found_invalid_extensions = true;
// Skip invalid extension
continue;
}
if let Some(ref platforms) = extension.platforms {
if !platforms.contains(&current_platform) {
log::warn!(
"installed third-party extension [developer {}, ID {}] is not compatible with current platform, either user messes our directory or something wrong with our extension check",
extension
.developer
.as_ref()
.expect("third party extension should have [developer] set"),
extension.id
);
continue;
}
}
/* Check ends here */
// Turn it into an absolute path if it is a valid relative path because frontend code needs this.
canonicalize_relative_icon_path(&extension_dir.path(), &mut extension)?;
canonicalize_relative_page_path(&extension_dir.path(), &mut extension)?;
extensions.push(extension);
}
}
@@ -198,7 +163,203 @@ pub(crate) async fn load_third_party_extensions_from_directory(
.collect::<Vec<_>>()
);
Ok(extensions)
Ok((found_invalid_extensions, extensions))
}
/// Helper function to validate `extension`, return `true` if it is valid.
fn validate_extension(
extension: &Extension,
extension_dir_name: &OsStr,
listed_extensions: &[Extension],
current_platform: Platform,
) -> bool {
if OsStr::new(&extension.id) != extension_dir_name {
log::warn!(
"invalid extension []: id [{}] and extension directory name [{}] do not match",
extension.id,
extension_dir_name.display()
);
return false;
}
// Extension ID should be unique
if listed_extensions.iter().any(|ext| ext.id == extension.id) {
log::warn!(
"invalid extension []: extension with id [{}] already exists",
extension.id,
);
return false;
}
if !validate_extension_or_sub_item(extension) {
return false;
}
// Extension is incompatible
if let Some(ref platforms) = extension.platforms {
if !platforms.contains(&current_platform) {
log::warn!(
"extension [{}] is not compatible with the current platform [{}], it is available to {:?}",
extension.id,
current_platform,
platforms
.iter()
.map(|os| os.to_string())
.collect::<Vec<_>>()
);
return false;
}
}
if let Some(ref commands) = extension.commands {
if !validate_sub_items(&extension.id, commands) {
return false;
}
}
if let Some(ref scripts) = extension.scripts {
if !validate_sub_items(&extension.id, scripts) {
return false;
}
}
if let Some(ref quicklinks) = extension.quicklinks {
if !validate_sub_items(&extension.id, quicklinks) {
return false;
}
}
true
}
/// Checks that can be performed against an extension or a sub item.
fn validate_extension_or_sub_item(extension: &Extension) -> bool {
// If field `action` is Some, then it should be a Command
if extension.action.is_some() && extension.r#type != ExtensionType::Command {
log::warn!(
"invalid extension [{}], [action] is set for a non-Command extension",
extension.id
);
return false;
}
if extension.r#type == ExtensionType::Command && extension.action.is_none() {
log::warn!(
"invalid extension [{}], [action] should be set for a Command extension",
extension.id
);
return false;
}
// If field `quicklink` is Some, then it should be a Quicklink
if extension.quicklink.is_some() && extension.r#type != ExtensionType::Quicklink {
log::warn!(
"invalid extension [{}], [quicklink] is set for a non-Quicklink extension",
extension.id
);
return false;
}
if extension.r#type == ExtensionType::Quicklink && extension.quicklink.is_none() {
log::warn!(
"invalid extension [{}], [quicklink] should be set for a Quicklink extension",
extension.id
);
return false;
}
// Group and Extension cannot have alias
if extension.alias.is_some() {
if extension.r#type == ExtensionType::Group || extension.r#type == ExtensionType::Extension
{
log::warn!(
"invalid extension [{}], extension of type [{:?}] cannot have alias",
extension.id,
extension.r#type
);
return false;
}
}
// Group and Extension cannot have hotkey
if extension.hotkey.is_some() {
if extension.r#type == ExtensionType::Group || extension.r#type == ExtensionType::Extension
{
log::warn!(
"invalid extension [{}], extension of type [{:?}] cannot have hotkey",
extension.id,
extension.r#type
);
return false;
}
}
if extension.commands.is_some() || extension.scripts.is_some() || extension.quicklinks.is_some()
{
if extension.r#type != ExtensionType::Group && extension.r#type != ExtensionType::Extension
{
log::warn!(
"invalid extension [{}], only extension of type [Group] and [Extension] can have sub-items",
extension.id,
);
return false;
}
}
true
}
/// Helper function to check sub-items.
fn validate_sub_items(extension_id: &str, sub_items: &[Extension]) -> bool {
for (sub_item_index, sub_item) in sub_items.iter().enumerate() {
// If field `action` is Some, then it should be a Command
if sub_item.action.is_some() && sub_item.r#type != ExtensionType::Command {
log::warn!(
"invalid extension sub-item [{}-{}]: [action] is set for a non-Command extension",
extension_id,
sub_item.id
);
return false;
}
if sub_item.r#type == ExtensionType::Group || sub_item.r#type == ExtensionType::Extension {
log::warn!(
"invalid extension sub-item [{}-{}]: sub-item should not be of type [Group] or [Extension]",
extension_id,
sub_item.id
);
return false;
}
let sub_item_with_same_id_count = sub_items
.iter()
.enumerate()
.filter(|(_idx, ext)| ext.id == sub_item.id)
.filter(|(idx, _ext)| *idx != sub_item_index)
.count();
if sub_item_with_same_id_count != 0 {
log::warn!(
"invalid extension [{}]: found more than one sub-items with the same ID [{}]",
extension_id,
sub_item.id
);
return false;
}
if !validate_extension_or_sub_item(sub_item) {
return false;
}
if sub_item.platforms.is_some() {
log::warn!(
"invalid extension [{}]: key [platforms] should not be set in sub-items",
extension_id,
);
return false;
}
}
true
}
/// All the third-party extensions will be registered as one search source.
@@ -206,7 +367,7 @@ pub(crate) async fn load_third_party_extensions_from_directory(
/// Since some `#[tauri::command]`s need to access it, we store it in a global
/// static variable as well.
#[derive(Debug, Clone)]
pub(crate) struct ThirdPartyExtensionsSearchSource {
pub(super) struct ThirdPartyExtensionsSearchSource {
inner: Arc<ThirdPartyExtensionsSearchSourceInner>,
}
@@ -258,7 +419,7 @@ impl ThirdPartyExtensionsSearchSource {
if event.state() == ShortcutState::Pressed {
async_runtime::spawn(async move {
let result = open(app_handle_clone, on_opened_clone, None).await;
let result = open(app_handle_clone, on_opened_clone).await;
if let Err(msg) = result {
log::warn!(
"failed to open extension [{}], error [{}]",
@@ -292,11 +453,6 @@ 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(())
@@ -339,11 +495,6 @@ 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(())
@@ -357,11 +508,6 @@ 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,
@@ -476,7 +622,7 @@ impl ThirdPartyExtensionsSearchSource {
/// Initialize the third-party extensions, which literally means
/// enabling/activating the enabled extensions.
pub(crate) async fn init(&self, tauri_app_handle: &AppHandle) -> Result<(), String> {
pub(super) 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) {
@@ -534,7 +680,7 @@ impl ThirdPartyExtensionsSearchSource {
if event.state() == ShortcutState::Pressed {
async_runtime::spawn(async move {
let result = open(app_handle_clone, on_opened_clone, None).await;
let result = open(app_handle_clone, on_opened_clone).await;
if let Err(msg) = result {
log::warn!(
"failed to open extension [{:?}], error [{}]",
@@ -647,68 +793,48 @@ impl ThirdPartyExtensionsSearchSource {
.any(|ext| ext.developer.as_deref() == Some(developer) && ext.id == extension_id)
}
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;
/// 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"
);
let Some(index) = write_lock
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
.iter()
.position(|ext| ext.developer.as_deref() == Some(developer) && ext.id == extension_id)
else {
return Err(format!(
"The extension we are trying to uninstall [{}/{}] does not exist",
panic!(
"extension [{}/{}] not installed, but we are trying to remove it",
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
);
};
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()
write_lock_guard.remove(index)
}
}
pub(crate) static THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE: OnceLock<ThirdPartyExtensionsSearchSource> =
pub(super) static THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE: OnceLock<ThirdPartyExtensionsSearchSource> =
OnceLock::new();
#[derive(Debug)]
@@ -788,16 +914,6 @@ impl SearchSource for ThirdPartyExtensionsSearchSource {
}
}
}
if let Some(ref views) = extension.views {
for view in views.iter().filter(|link| link.enabled) {
if let Some(hit) =
extension_to_hit(view, &query_lower, opt_data_source.as_deref())
{
hits.push(hit);
}
}
}
} else {
if let Some(hit) =
extension_to_hit(extension, &query_lower, opt_data_source.as_deref())
@@ -826,20 +942,7 @@ impl SearchSource for ThirdPartyExtensionsSearchSource {
}
}
#[tauri::command]
pub(crate) async fn uninstall_extension(
tauri_app_handle: AppHandle,
developer: String,
extension_id: String,
) -> Result<(), String> {
THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE
.get()
.expect("global third party search source not set")
.uninstall_extension(&tauri_app_handle, &developer, &extension_id)
.await
}
pub(crate) fn extension_to_hit(
fn extension_to_hit(
extension: &Extension,
query_lower: &str,
opt_data_source: Option<&str>,
@@ -908,3 +1011,183 @@ pub(crate) 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);
}
}

View File

@@ -1,7 +1,6 @@
//! Extension store related stuff.
use super::super::LOCAL_QUERY_SOURCE_TYPE;
use super::is_extension_installed;
use super::LOCAL_QUERY_SOURCE_TYPE;
use crate::common::document::DataSourceReference;
use crate::common::document::Document;
use crate::common::error::SearchError;
@@ -10,24 +9,16 @@ use crate::common::search::QuerySource;
use crate::common::search::SearchQuery;
use crate::common::traits::SearchSource;
use crate::extension::Extension;
use crate::extension::ExtensionType;
use crate::extension::PLUGIN_JSON_FILE_NAME;
use crate::extension::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE;
use crate::extension::canonicalize_relative_icon_path;
use crate::extension::canonicalize_relative_page_path;
use crate::extension::third_party::check::general_check;
use crate::extension::third_party::get_third_party_extension_directory;
use crate::extension::third_party::install::convert_page;
use crate::extension::third_party::install::filter_out_incompatible_sub_extensions;
use crate::server::http_client::HttpClient;
use crate::util::platform::Platform;
use async_trait::async_trait;
use reqwest::StatusCode;
use serde_json::Map as JsonObject;
use serde_json::Value as Json;
use std::io::Read;
use std::path::Path;
use std::path::PathBuf;
use tauri::AppHandle;
const DATA_SOURCE_ID: &str = "Extension Store";
@@ -161,12 +152,14 @@ pub(crate) async fn search_extension(
.get("developer")
.and_then(|dev| dev.get("id"))
.and_then(|id| id.as_str())
.expect("developer.id should exist");
.expect("developer.id should exist")
.to_string();
let extension_id = source_obj
.get("id")
.and_then(|id| id.as_str())
.expect("extension id should exist");
.expect("extension id should exist")
.to_string();
let installed = is_extension_installed(developer_id, extension_id).await;
source_obj.insert("installed".to_string(), Json::Bool(installed));
@@ -177,51 +170,12 @@ 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)
async fn is_extension_installed(developer: String, extension_id: String) -> bool {
THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE
.get()
.unwrap()
.extension_exists(&developer, &extension_id)
.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]
@@ -302,40 +256,15 @@ 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(&current_platform) {
return Err("this extension is not compatible with your OS".into());
}
}
if is_extension_installed(&developer_id, &id).await {
return Err("Extension already installed.".into());
}
// Extension is compatible with current platform, but it could contain sub
// extensions that are not, filter them out.
filter_out_incompatible_sub_extensions(&mut extension, current_platform);
// We are going to modify our third-party extension list, grab the write lock
// to ensure exclusive access.
let mut third_party_ext_list_write_lock = THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE
.get()
.expect("global third party search source not set")
.write_lock()
.await;
// Write extension files to the extension directory
let developer = extension.developer.clone().unwrap_or_default();
let extension_id = extension.id.clone();
let extension_directory = {
let mut path = get_third_party_extension_directory(&tauri_app_handle);
path.push(developer_id);
path.push(developer);
path.push(extension_id.as_str());
path
};
@@ -399,58 +328,14 @@ pub(crate) async fn install_extension_from_store(
.await
.map_err(|e| e.to_string())?;
/*
* Call convert_page() to update the page files. This has to be done after
* writing the extension files
*/
let absolute_page_paths: Vec<PathBuf> = {
fn canonicalize_page_path(page_path: &Path, extension_root: &Path) -> PathBuf {
if page_path.is_relative() {
// It is relative to the extension root directory
extension_root.join(page_path)
} else {
page_path.into()
}
}
if extension.r#type == ExtensionType::View {
let page = extension
.page
.as_ref()
.expect("View extension should set its page field");
let path = canonicalize_page_path(Path::new(page.as_str()), &extension_directory);
vec![path]
} else if extension.r#type.contains_sub_items()
&& let Some(ref views) = extension.views
{
let mut paths = Vec::with_capacity(views.len());
for view in views.iter() {
let page = view
.page
.as_ref()
.expect("View extension should set its page field");
let path = canonicalize_page_path(Path::new(page.as_str()), &extension_directory);
paths.push(path);
}
paths
} else {
// No pages in this extension
Vec::new()
}
};
for page_path in absolute_page_paths {
convert_page(&page_path).await?;
}
// Canonicalize relative icon and page paths
// 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)?;
canonicalize_relative_page_path(&extension_directory, &mut extension)?;
third_party_ext_list_write_lock.push(extension);
THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE
.get()
.unwrap()
.add_extension(extension)
.await;
Ok(())
}

View File

@@ -10,15 +10,16 @@ mod shortcut;
mod util;
use crate::common::register::SearchSourceRegistry;
// use crate::common::traits::SearchSource;
use crate::common::{CHECK_WINDOW_LABEL, MAIN_WINDOW_LABEL, SETTINGS_WINDOW_LABEL};
use crate::server::servers::{load_or_insert_default_server, load_servers_token};
use crate::util::prevent_default;
use autostart::change_autostart;
use autostart::{change_autostart, ensure_autostart_state_consistent};
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::{AppHandle, Emitter, Manager, PhysicalPosition, Runtime, WebviewWindow, WindowEvent};
use tauri_plugin_autostart::MacosLauncher;
/// Tauri store name
@@ -69,8 +70,10 @@ pub fn run() {
#[cfg(desktop)]
{
app_builder =
app_builder.plugin(tauri_plugin_single_instance::init(|_app, _argv, _cwd| {}));
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
@@ -93,8 +96,7 @@ pub fn run() {
.build(),
)
.plugin(tauri_plugin_windows_version::init())
.plugin(tauri_plugin_opener::init())
.plugin(prevent_default::init());
.plugin(tauri_plugin_opener::init());
// Conditional compilation for macOS
#[cfg(target_os = "macos")]
@@ -128,7 +130,9 @@ pub fn run() {
server::connector::get_connectors_by_server,
search::query_coco_fusion,
assistant::chat_history,
assistant::new_chat,
assistant::chat_create,
assistant::send_message,
assistant::chat_chat,
assistant::session_chat_history,
assistant::open_session_chat,
@@ -141,9 +145,11 @@ pub fn run() {
assistant::assistant_get_multi,
// server::get_coco_server_datasources,
// server::get_coco_server_connectors,
server::websocket::connect_to_server,
server::websocket::disconnect,
get_app_search_source,
server::attachment::upload_attachment,
server::attachment::get_attachment_by_ids,
server::attachment::get_attachment,
server::attachment::delete_attachment,
server::transcription::transcription,
server::system_settings::get_system_settings,
@@ -153,7 +159,6 @@ pub fn run() {
extension::built_in::application::add_app_search_path,
extension::built_in::application::remove_app_search_path,
extension::built_in::application::reindex_applications,
extension::quicklink_link_arguments,
extension::list_extensions,
extension::enable_extension,
extension::disable_extension,
@@ -161,26 +166,30 @@ pub fn run() {
extension::register_extension_hotkey,
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::store::search_extension,
extension::third_party::store::install_extension_from_store,
extension::third_party::uninstall_extension,
extension::api::apis,
extension::api::fs::read_dir,
settings::set_allow_self_signature,
settings::get_allow_self_signature,
assistant::ask_ai,
crate::common::document::open,
#[cfg(any(target_os = "macos", target_os = "windows"))]
extension::built_in::file_search::config::get_file_system_config,
#[cfg(any(target_os = "macos", target_os = "windows"))]
extension::built_in::file_search::config::set_file_system_config,
server::synthesize::synthesize,
util::file::get_file_icon,
setup::backend_setup,
util::app_lang::update_app_lang,
util::path::path_absolute,
#[cfg(target_os = "macos")]
setup::toggle_move_to_active_space_attribute,
])
.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");
@@ -188,21 +197,68 @@ pub fn run() {
log::trace!("Dock icon should be hidden now");
}
let registry = SearchSourceRegistry::default();
/* ----------- 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();
app.manage(registry); // Store registry in Tauri's app state
app.manage(server::websocket::WebSocketManager::default());
// This has to be called before initializing extensions as doing that
// requires access to the shortcut store, which will be set by this
// function.
shortcut::enable_shortcut(app);
block_on(async {
init(app.handle()).await;
// We want all the extensions here, so no filter condition specified.
match extension::list_extensions(app_handle.clone(), None, None, false).await {
Ok((_found_invalid_extensions, extensions)) => {
// Initializing extension relies on SearchSourceRegistry, so this should
// be executed after `app.manage(registry)`
if let Err(e) =
extension::init_extensions(app_handle.clone(), extensions).await
{
log::error!("initializing extensions failed with error [{}]", e);
}
}
Err(e) => {
log::error!("listing extensions failed with error [{}]", e);
}
}
});
ensure_autostart_state_consistent(app)?;
// app.listen("theme-changed", move |event| {
// if let Ok(payload) = serde_json::from_str::<ThemeChangedPayload>(event.payload()) {
// // switch_tray_icon(app.app_handle(), payload.is_dark_mode);
// log::debug!("Theme changed: is_dark_mode = {}", payload.is_dark_mode);
// }
// });
#[cfg(desktop)]
{
#[cfg(any(windows, target_os = "linux"))]
{
app.deep_link().register("coco")?;
use tauri_plugin_deep_link::DeepLinkExt;
app.deep_link().register_all()?;
}
}
// app.deep_link().on_open_url(|event| {
// dbg!(event.urls());
// });
let main_window = app.get_webview_window(MAIN_WINDOW_LABEL).unwrap();
let settings_window = app.get_webview_window(SETTINGS_WINDOW_LABEL).unwrap();
let check_window = app.get_webview_window(CHECK_WINDOW_LABEL).unwrap();
setup::default(
app_handle,
app,
main_window.clone(),
settings_window.clone(),
check_window.clone(),
);
/* ----------- This code must be executed on the main thread and must not be relocated. ----------- */
Ok(())
})
@@ -237,7 +293,7 @@ pub fn run() {
});
}
pub async fn init(app_handle: &AppHandle) {
pub async fn init<R: Runtime>(app_handle: &AppHandle<R>) {
// Await the async functions to load the servers and tokens
if let Err(err) = load_or_insert_default_server(app_handle).await {
log::error!("Failed to load servers: {}", err);
@@ -261,22 +317,12 @@ pub async fn init(app_handle: &AppHandle) {
}
#[tauri::command]
async fn show_coco(app_handle: AppHandle) {
async fn show_coco<R: Runtime>(app_handle: AppHandle<R>) {
if let Some(window) = app_handle.get_webview_window(MAIN_WINDOW_LABEL) {
move_window_to_active_monitor(&window);
let _ = window.show();
let _ = window.unminimize();
// The Window Management (WM) extension (macOS-only) controls the
// frontmost window. Setting focus on macOS makes Coco the frontmost
// window, which means the WM extension would control Coco instead of other
// windows, which is not what we want.
//
// On Linux/Windows, however, setting focus is a necessity to ensure that
// users open Coco's window, then they can start typing, without needing
// to click on the window.
#[cfg(not(target_os = "macos"))]
let _ = window.set_focus();
let _ = app_handle.emit("show-coco", ());
@@ -284,7 +330,7 @@ async fn show_coco(app_handle: AppHandle) {
}
#[tauri::command]
async fn hide_coco(app: AppHandle) {
async fn hide_coco<R: Runtime>(app: AppHandle<R>) {
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);
@@ -296,7 +342,7 @@ async fn hide_coco(app: AppHandle) {
}
}
fn move_window_to_active_monitor(window: &WebviewWindow) {
fn move_window_to_active_monitor<R: Runtime>(window: &WebviewWindow<R>) {
//dbg!("Moving window to active monitor");
// Try to get the available monitors, handle failure gracefully
let available_monitors = match window.available_monitors() {

View File

@@ -1,7 +1,7 @@
use crate::common::error::SearchError;
use crate::common::register::SearchSourceRegistry;
use crate::common::search::{
FailedRequest, MultiSourceQueryResponse, QueryHits, QuerySource, SearchQuery,
FailedRequest, MultiSourceQueryResponse, QueryHits, QueryResponse, QuerySource, SearchQuery,
};
use crate::common::traits::SearchSource;
use crate::server::servers::logout_coco_server;
@@ -13,24 +13,74 @@ use reqwest::StatusCode;
use std::cmp::Reverse;
use std::collections::HashMap;
use std::collections::HashSet;
use std::future::Future;
use std::sync::Arc;
use tauri::{AppHandle, Manager};
use tokio::time::error::Elapsed;
use tokio::time::{Duration, timeout};
/// Helper function to return the Future used for querying querysources.
///
/// It is a workaround for the limitations:
///
/// 1. 2 async blocks have different types in Rust's type system even though
/// they are literally same
/// 2. `futures::stream::FuturesUnordered` needs the `Futures` pushed to it to
/// have only 1 type
///
/// Putting the async block in a function to unify the types.
fn same_type_futures(
query_source: QuerySource,
query_source_trait_object: Arc<dyn SearchSource>,
timeout_duration: Duration,
search_query: SearchQuery,
tauri_app_handle: AppHandle,
) -> impl Future<
Output = (
QuerySource,
Result<Result<QueryResponse, SearchError>, Elapsed>,
),
> + 'static {
async move {
(
// Store `query_source` as part of future for debugging purposes.
query_source,
timeout(timeout_duration, async {
query_source_trait_object
.search(tauri_app_handle.clone(), search_query)
.await
})
.await,
)
}
}
#[named]
#[tauri::command]
pub async fn query_coco_fusion(
tauri_app_handle: AppHandle,
app_handle: AppHandle,
from: u64,
size: u64,
query_strings: HashMap<String, String>,
query_timeout: u64,
) -> Result<MultiSourceQueryResponse, SearchError> {
let query_keyword = query_strings
.get("query")
.unwrap_or(&"".to_string())
.clone();
let opt_query_source_id = query_strings.get("querysource");
let search_sources = tauri_app_handle.state::<SearchSourceRegistry>();
let query_source_list = search_sources.get_sources().await;
let search_sources = app_handle.state::<SearchSourceRegistry>();
let sources_future = search_sources.get_sources();
let mut futures = FuturesUnordered::new();
let mut sources_list = sources_future.await;
let sources_list_len = sources_list.len();
// Time limit for each query
let timeout_duration = Duration::from_millis(query_timeout);
let search_query = SearchQuery::new(from, size, query_strings.clone());
log::debug!(
"{}() invoked with parameters: from: [{}], size: [{}], query_strings: [{:?}], timeout: [{:?}]",
@@ -41,170 +91,68 @@ pub async fn query_coco_fusion(
timeout_duration
);
// Dispatch to different `query_coco_fusion_xxx()` functions.
let search_query = SearchQuery::new(from, size, query_strings.clone());
if let Some(query_source_id) = opt_query_source_id {
query_coco_fusion_single_query_source(
tauri_app_handle,
query_source_list,
query_source_id.clone(),
timeout_duration,
search_query,
)
.await
} else {
query_coco_fusion_multi_query_sources(
tauri_app_handle,
query_source_list,
timeout_duration,
search_query,
)
.await
}
}
// If this query source ID is specified, we only query this query source.
log::debug!(
"parameter [querysource={}] specified, will only query this querysource",
query_source_id
);
/// Query only 1 query source.
///
/// The logic here is much simpler than `query_coco_fusion_multi_query_sources()`
/// as we don't need to re-rank due to fact that this does not involve multiple
/// query sources.
async fn query_coco_fusion_single_query_source(
tauri_app_handle: AppHandle,
mut query_source_list: Vec<Arc<dyn SearchSource>>,
id_of_query_source_to_query: String,
timeout_duration: Duration,
search_query: SearchQuery,
) -> Result<MultiSourceQueryResponse, SearchError> {
// If this query source ID is specified, we only query this query source.
log::debug!(
"parameter [querysource={}] specified, will only query this query source",
id_of_query_source_to_query
);
let opt_query_source_trait_object_index = query_source_list
.iter()
.position(|query_source| query_source.get_type().id == id_of_query_source_to_query);
let Some(query_source_trait_object_index) = opt_query_source_trait_object_index else {
// It is possible (an edge case) that the frontend invokes `query_coco_fusion()`
// with a querysource that does not exist in the source list:
//
// 1. Search applications
// 2. Navigate to the application sub page
// 3. Disable the application extension in settings, which removes this
// query source from the list
// 4. hide the search window
// 5. Re-open the search window, you will still be in the sub page, type to search
// something
//
// The application query source is not in the source list because the extension
// was disabled and thus removed from the query sources, but the last
// search is indeed invoked with parameter `querysource=application`.
return Ok(MultiSourceQueryResponse {
failed: Vec::new(),
hits: Vec::new(),
total_hits: 0,
});
};
let query_source_trait_object = query_source_list.remove(query_source_trait_object_index);
let query_source = query_source_trait_object.get_type();
let search_fut = query_source_trait_object.search(tauri_app_handle.clone(), search_query);
let timeout_result = timeout(timeout_duration, search_fut).await;
let mut failed_requests: Vec<FailedRequest> = Vec::new();
let mut hits = Vec::new();
let mut total_hits = 0;
match timeout_result {
// Ignore the `_timeout` variable as it won't provide any useful debugging information.
Err(_timeout) => {
log::warn!(
"searching query source [{}] timed out, skip this request",
query_source.id
);
}
Ok(query_result) => match query_result {
Ok(response) => {
total_hits = response.total_hits;
for (document, score) in response.hits {
log::debug!(
"document from query source [{}]: ID [{}], title [{:?}], score [{}]",
response.source.id,
document.id,
document.title,
score
);
let query_hit = QueryHits {
source: Some(response.source.clone()),
score,
document,
};
hits.push(query_hit);
}
}
Err(search_error) => {
query_coco_fusion_handle_failed_request(
tauri_app_handle.clone(),
&mut failed_requests,
query_source,
search_error,
)
.await;
}
},
}
Ok(MultiSourceQueryResponse {
failed: failed_requests,
hits,
total_hits,
})
}
async fn query_coco_fusion_multi_query_sources(
tauri_app_handle: AppHandle,
query_source_trait_object_list: Vec<Arc<dyn SearchSource>>,
timeout_duration: Duration,
search_query: SearchQuery,
) -> Result<MultiSourceQueryResponse, SearchError> {
log::debug!(
"will query query sources {:?}",
query_source_trait_object_list
let opt_query_source_trait_object_index = sources_list
.iter()
.map(|search_source| search_source.get_type().id.clone())
.collect::<Vec<String>>()
);
.position(|query_source| &query_source.get_type().id == query_source_id);
let query_keyword = search_query
.query_strings
.get("query")
.unwrap_or(&"".to_string())
.clone();
let size = search_query.size;
let Some(query_source_trait_object_index) = opt_query_source_trait_object_index else {
// It is possible (an edge case) that the frontend invokes `query_coco_fusion()` with a
// datasource that does not exist in the source list:
//
// 1. Search applications
// 2. Navigate to the application sub page
// 3. Disable the application extension in settings
// 4. hide the search window
// 5. Re-open the search window and search for something
//
// The application search source is not in the source list because the extension
// has been disabled, but the last search is indeed invoked with parameter
// `datasource=application`.
return Ok(MultiSourceQueryResponse {
failed: Vec::new(),
hits: Vec::new(),
total_hits: 0,
});
};
let mut futures = FuturesUnordered::new();
let query_source_trait_object = sources_list.remove(query_source_trait_object_index);
let query_source = query_source_trait_object.get_type();
let query_source_list_len = query_source_trait_object_list.len();
for query_source_trait_object in query_source_trait_object_list {
let query_source = query_source_trait_object.get_type().clone();
let tauri_app_handle_clone = tauri_app_handle.clone();
let search_query_clone = search_query.clone();
futures.push(same_type_futures(
query_source,
query_source_trait_object,
timeout_duration,
search_query,
app_handle.clone(),
));
} else {
log::debug!(
"will query querysources {:?}",
sources_list
.iter()
.map(|search_source| search_source.get_type().id.clone())
.collect::<Vec<String>>()
);
futures.push(async move {
(
// Store `query_source` as part of future for debugging purposes.
for query_source_trait_object in sources_list {
let query_source = query_source_trait_object.get_type().clone();
futures.push(same_type_futures(
query_source,
timeout(timeout_duration, async {
query_source_trait_object
.search(tauri_app_handle_clone, search_query_clone)
.await
})
.await,
)
});
query_source_trait_object,
timeout_duration,
search_query.clone(),
app_handle.clone(),
));
}
}
let mut total_hits = 0;
@@ -213,7 +161,7 @@ async fn query_coco_fusion_multi_query_sources(
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 {
if sources_list_len > 1 {
need_rerank = true; // If we have more than one source, we need to rerank the hits
}
@@ -225,25 +173,25 @@ async fn query_coco_fusion_multi_query_sources(
"searching query source [{}] timed out, skip this request",
query_source.id
);
// failed_requests.push(FailedRequest {
// source: query_source,
// status: 0,
// error: Some("querying timed out".into()),
// reason: None,
// });
}
Ok(query_result) => match query_result {
Ok(response) => {
total_hits += response.total_hits;
let source_id = response.source.id.clone();
for (document, score) in response.hits {
log::debug!(
"document from query source [{}]: ID [{}], title [{:?}], score [{}]",
response.source.id,
document.id,
document.title,
score
);
for (doc, score) in response.hits {
log::debug!("doc: {}, {:?}, {}", doc.id, doc.title, score);
let query_hit = QueryHits {
source: Some(response.source.clone()),
score,
document,
document: doc,
};
all_hits.push((source_id.clone(), query_hit.clone(), score));
@@ -255,13 +203,46 @@ async fn query_coco_fusion_multi_query_sources(
}
}
Err(search_error) => {
query_coco_fusion_handle_failed_request(
tauri_app_handle.clone(),
&mut failed_requests,
query_source,
search_error,
)
.await;
log::error!(
"searching query source [{}] failed, error [{}]",
query_source.id,
search_error
);
let mut status_code_num: u16 = 0;
if let SearchError::HttpError {
status_code: opt_status_code,
msg: _,
} = search_error
{
if let Some(status_code) = opt_status_code {
status_code_num = status_code.as_u16();
if status_code != StatusCode::OK {
if status_code == StatusCode::UNAUTHORIZED {
// This Coco server is unavailable. In addition to marking it as
// unavailable, we need to log out because the status code is 401.
logout_coco_server(app_handle.clone(), query_source.id.clone()).await.unwrap_or_else(|e| {
panic!(
"the search request to Coco server [id {}, name {}] failed with status code {}, the login token is invalid, we are trying to log out, but failed with error [{}]",
query_source.id, query_source.name, StatusCode::UNAUTHORIZED, e
);
})
} else {
// This Coco server is unavailable
mark_server_as_offline(app_handle.clone(), &query_source.id)
.await;
}
}
}
}
failed_requests.push(FailedRequest {
source: query_source,
status: status_code_num,
error: Some(search_error.to_string()),
reason: None,
});
}
},
}
@@ -421,54 +402,3 @@ fn boosted_levenshtein_rerank(query: &str, titles: Vec<(usize, &str)>) -> Vec<(u
})
.collect()
}
/// Helper function to handle a failed request.
///
/// Extracted as a function because `query_coco_fusion_single_query_source()` and
/// `query_coco_fusion_multi_query_sources()` share the same error handling logic.
async fn query_coco_fusion_handle_failed_request(
tauri_app_handle: AppHandle,
failed_requests: &mut Vec<FailedRequest>,
query_source: QuerySource,
search_error: SearchError,
) {
log::error!(
"searching query source [{}] failed, error [{}]",
query_source.id,
search_error
);
let mut status_code_num: u16 = 0;
if let SearchError::HttpError {
status_code: opt_status_code,
msg: _,
} = search_error
{
if let Some(status_code) = opt_status_code {
status_code_num = status_code.as_u16();
if status_code != StatusCode::OK {
if status_code == StatusCode::UNAUTHORIZED {
// This Coco server is unavailable. In addition to marking it as
// unavailable, we need to log out because the status code is 401.
logout_coco_server(tauri_app_handle.clone(), query_source.id.to_string()).await.unwrap_or_else(|e| {
panic!(
"the search request to Coco server [id {}, name {}] failed with status code {}, the login token is invalid, we are trying to log out, but failed with error [{}]",
query_source.id, query_source.name, StatusCode::UNAUTHORIZED, e
);
})
} else {
// This Coco server is unavailable
mark_server_as_offline(tauri_app_handle.clone(), &query_source.id).await;
}
}
}
}
failed_requests.push(FailedRequest {
source: query_source,
status: status_code_num,
error: Some(search_error.to_string()),
reason: None,
});
}

View File

@@ -72,19 +72,11 @@ pub async fn upload_attachment(
}
#[command]
pub async fn get_attachment_by_ids(
server_id: String,
attachments: Vec<String>,
) -> Result<Value, String> {
println!("get_attachment_by_ids server_id: {}", server_id);
println!("get_attachment_by_ids attachments: {:?}", attachments);
pub async fn get_attachment(server_id: String, session_id: String) -> Result<Value, String> {
let mut query_params = Vec::new();
query_params.push(format!("session={}", session_id));
let request_body = serde_json::json!({
"attachments": attachments
});
let body = reqwest::Body::from(serde_json::to_string(&request_body).unwrap());
let response = HttpClient::post(&server_id, "/attachment/_search", None, Some(body))
let response = HttpClient::get(&server_id, "/attachment/_search", Some(query_params))
.await
.map_err(|e| format!("Request error: {}", e))?;

View File

@@ -4,17 +4,17 @@ use crate::server::servers::{
get_server_by_id, persist_servers, persist_servers_token, save_access_token, save_server,
try_register_server_to_search_source,
};
use tauri::AppHandle;
use tauri::{AppHandle, Runtime};
#[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/access_token?request_id={}", request_id)
format!("/auth/request_access_token?request_id={}", request_id)
}
#[tauri::command]
pub async fn handle_sso_callback(
app_handle: AppHandle,
pub async fn handle_sso_callback<R: Runtime>(
app_handle: AppHandle<R>,
server_id: String,
request_id: String,
code: String,

View File

@@ -6,7 +6,7 @@ use http::StatusCode;
use lazy_static::lazy_static;
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
use tauri::AppHandle;
use tauri::{AppHandle, Runtime};
lazy_static! {
static ref CONNECTOR_CACHE: Arc<RwLock<HashMap<String, HashMap<String, Connector>>>> =
@@ -29,7 +29,7 @@ pub fn get_connector_by_id(server_id: &str, connector_id: &str) -> Option<Connec
Some(connector.clone())
}
pub async fn refresh_all_connectors(app_handle: &AppHandle) -> Result<(), String> {
pub async fn refresh_all_connectors<R: Runtime>(app_handle: &AppHandle<R>) -> Result<(), String> {
let servers = get_all_servers().await;
// Collect all the tasks for fetching and refreshing connectors
@@ -122,8 +122,8 @@ pub async fn fetch_connectors_by_server(id: &str) -> Result<Vec<Connector>, Stri
}
#[tauri::command]
pub async fn get_connectors_by_server(
_app_handle: AppHandle,
pub async fn get_connectors_by_server<R: Runtime>(
_app_handle: AppHandle<R>,
id: String,
) -> Result<Vec<Connector>, String> {
let connectors = fetch_connectors_by_server(&id).await?;

View File

@@ -7,7 +7,7 @@ use http::StatusCode;
use lazy_static::lazy_static;
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
use tauri::AppHandle;
use tauri::{AppHandle, Runtime};
lazy_static! {
static ref DATASOURCE_CACHE: Arc<RwLock<HashMap<String, HashMap<String, DataSource>>>> =
@@ -31,7 +31,7 @@ pub fn get_datasources_from_cache(server_id: &str) -> Option<HashMap<String, Dat
Some(server_cache.clone())
}
pub async fn refresh_all_datasources(_app_handle: &AppHandle) -> Result<(), String> {
pub async fn refresh_all_datasources<R: Runtime>(_app_handle: &AppHandle<R>) -> Result<(), String> {
// dbg!("Attempting to refresh all datasources");
let servers = get_all_servers().await;

View File

@@ -11,3 +11,4 @@ pub mod servers;
pub mod synthesize;
pub mod system_settings;
pub mod transcription;
pub mod websocket;

View File

@@ -1,11 +1,11 @@
use crate::common::http::get_response_body_text;
use crate::common::profile::UserProfile;
use crate::server::http_client::HttpClient;
use tauri::AppHandle;
use tauri::{AppHandle, Runtime};
#[tauri::command]
pub async fn get_user_profiles(
_app_handle: AppHandle,
pub async fn get_user_profiles<R: Runtime>(
_app_handle: AppHandle<R>,
server_id: String,
) -> Result<UserProfile, String> {
// Use the generic GET method from HttpClient

View File

@@ -13,6 +13,7 @@ use serde_json::Value as JsonValue;
use serde_json::from_value;
use std::collections::HashMap;
use std::sync::LazyLock;
use tauri::Runtime;
use tauri::{AppHandle, Manager};
use tauri_plugin_store::StoreExt;
use tokio::sync::RwLock;
@@ -69,7 +70,7 @@ async fn remove_server_by_id(id: &str) -> Option<Server> {
cache.remove(id)
}
pub async fn persist_servers(app_handle: &AppHandle) -> Result<(), String> {
pub async fn persist_servers<R: Runtime>(app_handle: &AppHandle<R>) -> Result<(), String> {
let cache = SERVER_LIST_CACHE.read().await;
// Convert HashMap to Vec for serialization (iterating over values of HashMap)
@@ -98,7 +99,7 @@ pub async fn remove_server_token(id: &str) -> bool {
cache.remove(id).is_some()
}
pub async fn persist_servers_token(app_handle: &AppHandle) -> Result<(), String> {
pub async fn persist_servers_token<R: Runtime>(app_handle: &AppHandle<R>) -> Result<(), String> {
let cache = SERVER_TOKEN_LIST_CACHE.read().await;
// Convert HashMap to Vec for serialization (iterating over values of HashMap)
@@ -157,7 +158,9 @@ fn get_default_server() -> Server {
}
}
pub async fn load_servers_token(app_handle: &AppHandle) -> Result<Vec<ServerAccessToken>, String> {
pub async fn load_servers_token<R: Runtime>(
app_handle: &AppHandle<R>,
) -> Result<Vec<ServerAccessToken>, String> {
log::debug!("Attempting to load servers token");
let store = app_handle
@@ -216,7 +219,7 @@ pub async fn load_servers_token(app_handle: &AppHandle) -> Result<Vec<ServerAcce
}
}
pub async fn load_servers(app_handle: &AppHandle) -> Result<Vec<Server>, String> {
pub async fn load_servers<R: Runtime>(app_handle: &AppHandle<R>) -> Result<Vec<Server>, String> {
let store = app_handle
.store(COCO_TAURI_STORE)
.expect("create or load a store should not fail");
@@ -273,7 +276,9 @@ pub async fn load_servers(app_handle: &AppHandle) -> Result<Vec<Server>, String>
}
/// Function to load servers or insert a default one if none exist
pub async fn load_or_insert_default_server(app_handle: &AppHandle) -> Result<Vec<Server>, String> {
pub async fn load_or_insert_default_server<R: Runtime>(
app_handle: &AppHandle<R>,
) -> Result<Vec<Server>, String> {
log::debug!("Attempting to load or insert default server");
let exists_servers = load_servers(&app_handle).await;
@@ -291,7 +296,9 @@ pub async fn load_or_insert_default_server(app_handle: &AppHandle) -> Result<Vec
}
#[tauri::command]
pub async fn list_coco_servers(app_handle: AppHandle) -> Result<Vec<Server>, String> {
pub async fn list_coco_servers<R: Runtime>(
app_handle: AppHandle<R>,
) -> Result<Vec<Server>, String> {
//hard fresh all server's info, in order to get the actual health
refresh_all_coco_server_info(app_handle.clone()).await;
@@ -305,7 +312,7 @@ pub async fn get_all_servers() -> Vec<Server> {
cache.values().cloned().collect()
}
pub async fn refresh_all_coco_server_info(app_handle: AppHandle) {
pub async fn refresh_all_coco_server_info<R: Runtime>(app_handle: AppHandle<R>) {
let servers = get_all_servers().await;
for server in servers {
let _ = refresh_coco_server_info(app_handle.clone(), server.id.clone()).await;
@@ -313,7 +320,10 @@ pub async fn refresh_all_coco_server_info(app_handle: AppHandle) {
}
#[tauri::command]
pub async fn refresh_coco_server_info(app_handle: AppHandle, id: String) -> Result<Server, String> {
pub async fn refresh_coco_server_info<R: Runtime>(
app_handle: AppHandle<R>,
id: String,
) -> Result<Server, String> {
// Retrieve the server from the cache
let cached_server = {
let cache = SERVER_LIST_CACHE.read().await;
@@ -383,7 +393,10 @@ pub async fn refresh_coco_server_info(app_handle: AppHandle, id: String) -> Resu
}
#[tauri::command]
pub async fn add_coco_server(app_handle: AppHandle, endpoint: String) -> Result<Server, String> {
pub async fn add_coco_server<R: Runtime>(
app_handle: AppHandle<R>,
endpoint: String,
) -> Result<Server, String> {
load_or_insert_default_server(&app_handle)
.await
.map_err(|e| format!("Failed to load default servers: {}", e))?;
@@ -459,7 +472,10 @@ pub async fn add_coco_server(app_handle: AppHandle, endpoint: String) -> Result<
#[tauri::command]
#[function_name::named]
pub async fn remove_coco_server(app_handle: AppHandle, id: String) -> Result<(), ()> {
pub async fn remove_coco_server<R: Runtime>(
app_handle: AppHandle<R>,
id: String,
) -> Result<(), ()> {
let registry = app_handle.state::<SearchSourceRegistry>();
registry.remove_source(id.as_str()).await;
@@ -491,7 +507,7 @@ pub async fn remove_coco_server(app_handle: AppHandle, id: String) -> Result<(),
#[tauri::command]
#[function_name::named]
pub async fn enable_server(app_handle: AppHandle, id: String) -> Result<(), ()> {
pub async fn enable_server<R: Runtime>(app_handle: AppHandle<R>, id: String) -> Result<(), ()> {
let opt_server = get_server_by_id(id.as_str()).await;
let Some(mut server) = opt_server else {
@@ -516,7 +532,7 @@ pub async fn enable_server(app_handle: AppHandle, id: String) -> Result<(), ()>
#[tauri::command]
#[function_name::named]
pub async fn disable_server(app_handle: AppHandle, id: String) -> Result<(), ()> {
pub async fn disable_server<R: Runtime>(app_handle: AppHandle<R>, id: String) -> Result<(), ()> {
let opt_server = get_server_by_id(id.as_str()).await;
let Some(mut server) = opt_server else {
@@ -544,7 +560,10 @@ pub async fn disable_server(app_handle: AppHandle, id: String) -> Result<(), ()>
/// enabled.
///
/// For public Coco server, an extra token is required.
pub async fn try_register_server_to_search_source(app_handle: AppHandle, server: &Server) {
pub async fn try_register_server_to_search_source(
app_handle: AppHandle<impl Runtime>,
server: &Server,
) {
if server.enabled {
log::trace!(
"Server [name: {}, id: {}] is public: {} and available: {}",
@@ -571,7 +590,7 @@ pub async fn try_register_server_to_search_source(app_handle: AppHandle, server:
#[function_name::named]
#[allow(unused)]
async fn mark_server_as_online(app_handle: AppHandle, id: &str) {
async fn mark_server_as_online<R: Runtime>(app_handle: AppHandle<R>, id: &str) {
let server = get_server_by_id(id).await;
if let Some(mut server) = server {
server.available = true;
@@ -589,7 +608,7 @@ async fn mark_server_as_online(app_handle: AppHandle, id: &str) {
}
#[function_name::named]
pub(crate) async fn mark_server_as_offline(app_handle: AppHandle, id: &str) {
pub(crate) async fn mark_server_as_offline<R: Runtime>(app_handle: AppHandle<R>, id: &str) {
let server = get_server_by_id(id).await;
if let Some(mut server) = server {
server.available = false;
@@ -609,7 +628,10 @@ pub(crate) async fn mark_server_as_offline(app_handle: AppHandle, id: &str) {
#[tauri::command]
#[function_name::named]
pub async fn logout_coco_server(app_handle: AppHandle, id: String) -> Result<(), String> {
pub async fn logout_coco_server<R: Runtime>(
app_handle: AppHandle<R>,
id: String,
) -> Result<(), String> {
log::debug!("Attempting to log out server by id: {}", &id);
// Check if the server exists

View File

@@ -2,11 +2,11 @@ use crate::server::http_client::HttpClient;
use futures_util::StreamExt;
use http::Method;
use serde_json::json;
use tauri::{AppHandle, Emitter, command};
use tauri::{AppHandle, Emitter, Runtime, command};
#[command]
pub async fn synthesize(
app_handle: AppHandle,
pub async fn synthesize<R: Runtime>(
app_handle: AppHandle<R>,
client_id: String,
server_id: String,
voice: String,

View File

@@ -0,0 +1,172 @@
use crate::server::servers::{get_server_by_id, get_server_token};
use futures::StreamExt;
use std::collections::HashMap;
use std::sync::Arc;
use tauri::{AppHandle, Emitter, Runtime};
use tokio::net::TcpStream;
use tokio::sync::{Mutex, mpsc};
use tokio_tungstenite::MaybeTlsStream;
use tokio_tungstenite::WebSocketStream;
use tokio_tungstenite::tungstenite::Message;
use tokio_tungstenite::tungstenite::handshake::client::generate_key;
use tokio_tungstenite::{Connector, connect_async_tls_with_config};
#[derive(Default)]
pub struct WebSocketManager {
connections: Arc<Mutex<HashMap<String, Arc<WebSocketInstance>>>>,
}
struct WebSocketInstance {
ws_connection: Mutex<WebSocketStream<MaybeTlsStream<TcpStream>>>, // No need to lock the entire map
cancel_tx: mpsc::Sender<()>,
}
fn convert_to_websocket(endpoint: &str) -> Result<String, String> {
let url = url::Url::parse(endpoint).map_err(|e| format!("Invalid URL: {}", e))?;
let ws_protocol = if url.scheme() == "https" {
"wss://"
} else {
"ws://"
};
let host = url.host_str().ok_or("No host found in URL")?;
let port = url
.port_or_known_default()
.unwrap_or(if url.scheme() == "https" { 443 } else { 80 });
let ws_endpoint = if port == 80 || port == 443 {
format!("{}{}{}", ws_protocol, host, "/ws")
} else {
format!("{}{}:{}/ws", ws_protocol, host, port)
};
Ok(ws_endpoint)
}
#[tauri::command]
pub async fn connect_to_server<R: Runtime>(
tauri_app_handle: AppHandle<R>,
id: String,
client_id: String,
state: tauri::State<'_, WebSocketManager>,
app_handle: AppHandle,
) -> Result<(), String> {
let connections_clone = state.connections.clone();
// Disconnect old connection first
disconnect(client_id.clone(), state.clone()).await.ok();
let server = get_server_by_id(&id)
.await
.ok_or(format!("Server with ID {} not found", id))?;
let endpoint = convert_to_websocket(&server.endpoint)?;
let token = get_server_token(&id).await.map(|t| t.access_token.clone());
let mut request =
tokio_tungstenite::tungstenite::client::IntoClientRequest::into_client_request(&endpoint)
.map_err(|e| format!("Failed to create WebSocket request: {}", e))?;
request
.headers_mut()
.insert("Connection", "Upgrade".parse().unwrap());
request
.headers_mut()
.insert("Upgrade", "websocket".parse().unwrap());
request
.headers_mut()
.insert("Sec-WebSocket-Version", "13".parse().unwrap());
request
.headers_mut()
.insert("Sec-WebSocket-Key", generate_key().parse().unwrap());
if let Some(token) = token {
request
.headers_mut()
.insert("X-API-TOKEN", token.parse().unwrap());
}
let allow_self_signature =
crate::settings::get_allow_self_signature(tauri_app_handle.clone()).await;
let tls_connector = tokio_native_tls::native_tls::TlsConnector::builder()
.danger_accept_invalid_certs(allow_self_signature)
.build()
.map_err(|e| format!("TLS build error: {:?}", e))?;
let connector = Connector::NativeTls(tls_connector.into());
let (ws_stream, _) = connect_async_tls_with_config(
request,
None, // WebSocketConfig
true, // disable_nagle
Some(connector), // Connector
)
.await
.map_err(|e| format!("WebSocket TLS error: {:?}", e))?;
let (cancel_tx, mut cancel_rx) = mpsc::channel(1);
let instance = Arc::new(WebSocketInstance {
ws_connection: Mutex::new(ws_stream),
cancel_tx,
});
// Insert connection into the map (lock is held briefly)
{
let mut connections = connections_clone.lock().await;
connections.insert(client_id.clone(), instance.clone());
}
// Spawn WebSocket handler in a separate task
let app_handle_clone = app_handle.clone();
let client_id_clone = client_id.clone();
tokio::spawn(async move {
let ws = &mut *instance.ws_connection.lock().await;
loop {
tokio::select! {
msg = ws.next() => {
match msg {
Some(Ok(Message::Text(text))) => {
let _ = app_handle_clone.emit(&format!("ws-message-{}", client_id_clone), text);
},
Some(Err(_)) | None => {
log::debug!("WebSocket connection closed or error");
let _ = app_handle_clone.emit(&format!("ws-error-{}", client_id_clone), id.clone());
break;
}
_ => {}
}
}
_ = cancel_rx.recv() => {
log::debug!("WebSocket connection cancelled");
let _ = app_handle_clone.emit(&format!("ws-cancel-{}", client_id_clone), id.clone());
break;
}
}
}
// Remove connection after it closes
let mut connections = connections_clone.lock().await;
connections.remove(&client_id_clone);
});
Ok(())
}
#[tauri::command]
pub async fn disconnect(
client_id: String,
state: tauri::State<'_, WebSocketManager>,
) -> Result<(), String> {
let instance = {
let mut connections = state.connections.lock().await;
connections.remove(&client_id)
};
if let Some(instance) = instance {
let _ = instance.cancel_tx.send(()).await;
// Close WebSocket (lock only the connection, not the whole map)
let mut ws = instance.ws_connection.lock().await;
let _ = ws.close(None).await;
}
Ok(())
}

View File

@@ -1,12 +1,12 @@
use crate::COCO_TAURI_STORE;
use serde_json::Value as Json;
use tauri::AppHandle;
use tauri::{AppHandle, Runtime};
use tauri_plugin_store::StoreExt;
const SETTINGS_ALLOW_SELF_SIGNATURE: &str = "settings_allow_self_signature";
#[tauri::command]
pub async fn set_allow_self_signature(tauri_app_handle: AppHandle, value: bool) {
pub async fn set_allow_self_signature<R: Runtime>(tauri_app_handle: AppHandle<R>, value: bool) {
use crate::server::http_client;
let store = tauri_app_handle
@@ -40,7 +40,7 @@ pub async fn set_allow_self_signature(tauri_app_handle: AppHandle, value: bool)
}
/// Synchronous version of `async get_allow_self_signature()`.
pub fn _get_allow_self_signature(tauri_app_handle: AppHandle) -> bool {
pub fn _get_allow_self_signature<R: Runtime>(tauri_app_handle: AppHandle<R>) -> bool {
let store = tauri_app_handle
.store(COCO_TAURI_STORE)
.unwrap_or_else(|e| {
@@ -67,6 +67,6 @@ pub fn _get_allow_self_signature(tauri_app_handle: AppHandle) -> bool {
}
#[tauri::command]
pub async fn get_allow_self_signature(tauri_app_handle: AppHandle) -> bool {
pub async fn get_allow_self_signature<R: Runtime>(tauri_app_handle: AppHandle<R>) -> bool {
_get_allow_self_signature(tauri_app_handle)
}

View File

@@ -1,7 +1,7 @@
use tauri::{AppHandle, WebviewWindow};
use tauri::{App, WebviewWindow};
pub fn platform(
_tauri_app_handle: &AppHandle,
_app: &mut App,
_main_window: WebviewWindow,
_settings_window: WebviewWindow,
_check_window: WebviewWindow,

View File

@@ -1,17 +1,22 @@
//! credits to: https://github.com/ayangweb/ayangweb-EcoPaste/blob/169323dbe6365ffe4abb64d867439ed2ea84c6d1/src-tauri/src/core/setup/mac.rs
use crate::common::MAIN_WINDOW_LABEL;
use objc2_app_kit::NSNonactivatingPanelMask;
use tauri::{AppHandle, Emitter, EventTarget, WebviewWindow};
use 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;
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";
pub fn platform(
_tauri_app_handle: &AppHandle,
_app: &mut App,
main_window: WebviewWindow,
_settings_window: WebviewWindow,
_check_window: WebviewWindow,
@@ -19,19 +24,13 @@ pub fn platform(
// Convert ns_window to ns_panel
let panel = main_window.to_panel().unwrap();
// Do not steal focus from other windows
//
// Cast is safe
panel.set_style_mask(NSNonactivatingPanelMask.0 as i32);
// Set its level to NSFloatingWindowLevel to ensure it appears in front of
// all normal-level windows
//
// NOTE: some Chinese input methods use a level between NSDockWindowLevel (20)
// and NSMainMenuWindowLevel (24), setting our level above NSDockWindowLevel
// would block their window
panel.set_floating_panel(true);
// Make the window above the dock
panel.set_level(20);
// Open the window in the active workspace and full screen
// Do not steal focus from other windows
panel.set_style_mask(NSWindowStyleMaskNonActivatingPanel);
// Share the window across all desktop spaces and full screen
panel.set_collection_behaviour(
NSWindowCollectionBehavior::NSWindowCollectionBehaviorMoveToActiveSpace
| NSWindowCollectionBehavior::NSWindowCollectionBehaviorStationary
@@ -82,3 +81,50 @@ pub fn platform(
// Set the delegate object for the window to handle window events
panel.set_delegate(delegate);
}
/// Change NS window attribute between `NSWindowCollectionBehaviorCanJoinAllSpaces`
/// and `NSWindowCollectionBehaviorMoveToActiveSpace` accordingly.
///
/// NOTE: this tauri command is not async because we should run it in the main
/// thread, or `ns_window.setCollectionBehavior_(collection_behavior)` would lead
/// to UB.
#[tauri::command]
pub(crate) fn toggle_move_to_active_space_attribute(tauri_app_hanlde: AppHandle) {
use cocoa::appkit::NSWindowCollectionBehavior;
use cocoa::base::id;
let main_window = tauri_app_hanlde
.get_webview_window(MAIN_WINDOW_LABEL)
.unwrap();
let ns_window = main_window.ns_window().unwrap() as id;
let mut collection_behavior = unsafe { ns_window.collectionBehavior() };
let join_all_spaces = collection_behavior
.contains(NSWindowCollectionBehavior::NSWindowCollectionBehaviorCanJoinAllSpaces);
let move_to_active_space = collection_behavior
.contains(NSWindowCollectionBehavior::NSWindowCollectionBehaviorMoveToActiveSpace);
match (join_all_spaces, move_to_active_space) {
(true, false) => {
collection_behavior
.remove(NSWindowCollectionBehavior::NSWindowCollectionBehaviorCanJoinAllSpaces);
collection_behavior
.insert(NSWindowCollectionBehavior::NSWindowCollectionBehaviorMoveToActiveSpace);
}
(false, true) => {
collection_behavior
.remove(NSWindowCollectionBehavior::NSWindowCollectionBehaviorMoveToActiveSpace);
collection_behavior
.insert(NSWindowCollectionBehavior::NSWindowCollectionBehaviorCanJoinAllSpaces);
}
_ => {
panic!(
"invalid NS window attribute, NSWindowCollectionBehaviorCanJoinAllSpaces is set [{}], NSWindowCollectionBehaviorMoveToActiveSpace is set [{}]",
join_all_spaces, move_to_active_space
);
}
}
unsafe {
ns_window.setCollectionBehavior_(collection_behavior);
}
}

View File

@@ -1,10 +1,4 @@
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};
use tauri::{App, WebviewWindow};
#[cfg(target_os = "macos")]
mod mac;
@@ -25,7 +19,7 @@ pub use windows::*;
pub use linux::*;
pub fn default(
tauri_app_handle: &AppHandle,
app: &mut App,
main_window: WebviewWindow,
settings_window: WebviewWindow,
check_window: WebviewWindow,
@@ -35,83 +29,9 @@ pub fn default(
main_window.open_devtools();
platform(
tauri_app_handle,
app,
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);
}

View File

@@ -1,7 +1,7 @@
use tauri::{AppHandle, WebviewWindow};
use tauri::{App, WebviewWindow};
pub fn platform(
_tauri_app_handle: &AppHandle,
_app: &mut App,
_main_window: WebviewWindow,
_settings_window: WebviewWindow,
_check_window: WebviewWindow,

View File

@@ -1,6 +1,5 @@
use crate::common::MAIN_WINDOW_LABEL;
use crate::{COCO_TAURI_STORE, hide_coco, show_coco};
use tauri::{AppHandle, Manager, async_runtime};
use tauri::{App, AppHandle, Manager, Runtime, async_runtime};
use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState};
use tauri_plugin_store::{JsonValue, StoreExt};
@@ -17,9 +16,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(tauri_app_handle: &AppHandle) {
pub fn enable_shortcut(app: &App) {
log::trace!("setting up Coco hotkey");
let store = tauri_app_handle
let store = app
.store(COCO_TAURI_STORE)
.expect("creating a store should not fail");
@@ -34,7 +33,7 @@ pub fn enable_shortcut(tauri_app_handle: &AppHandle) {
let stored_shortcut = stored_shortcut_str
.parse::<Shortcut>()
.expect("stored shortcut string should be valid");
_register_shortcut_upon_start(tauri_app_handle, stored_shortcut);
_register_shortcut_upon_start(app, stored_shortcut);
} else {
store.set(
COCO_GLOBAL_SHORTCUT,
@@ -43,7 +42,7 @@ pub fn enable_shortcut(tauri_app_handle: &AppHandle) {
let default_shortcut = DEFAULT_SHORTCUT
.parse::<Shortcut>()
.expect("default shortcut should never be invalid");
_register_shortcut_upon_start(tauri_app_handle, default_shortcut);
_register_shortcut_upon_start(app, default_shortcut);
}
log::trace!("Coco hotkey has been set");
}
@@ -51,14 +50,14 @@ pub fn enable_shortcut(tauri_app_handle: &AppHandle) {
/// Get the stored shortcut as a string, same as [`_get_shortcut()`], except that
/// this is a `tauri::command` interface.
#[tauri::command]
pub async fn get_current_shortcut(app: AppHandle) -> Result<String, String> {
pub async fn get_current_shortcut<R: Runtime>(app: AppHandle<R>) -> Result<String, String> {
let shortcut = _get_shortcut(&app);
Ok(shortcut)
}
/// Get the current shortcut and unregister it on the tauri side.
#[tauri::command]
pub async fn unregister_shortcut(app: AppHandle) {
pub async fn unregister_shortcut<R: Runtime>(app: AppHandle<R>) {
let shortcut_str = _get_shortcut(&app);
let shortcut = shortcut_str
.parse::<Shortcut>()
@@ -71,9 +70,9 @@ pub async fn unregister_shortcut(app: AppHandle) {
/// Change the global shortcut to `key`.
#[tauri::command]
pub async fn change_shortcut(
app: AppHandle,
_window: tauri::Window,
pub async fn change_shortcut<R: Runtime>(
app: AppHandle<R>,
_window: tauri::Window<R>,
key: String,
) -> Result<(), String> {
println!("key {}:", key);
@@ -95,7 +94,7 @@ pub async fn change_shortcut(
}
/// Helper function to register a shortcut, used for shortcut updates.
fn _register_shortcut(app: &AppHandle, shortcut: Shortcut) {
fn _register_shortcut<R: Runtime>(app: &AppHandle<R>, shortcut: Shortcut) {
app.global_shortcut()
.on_shortcut(shortcut, move |app, scut, event| {
if scut == &shortcut {
@@ -119,9 +118,12 @@ 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(tauri_app_handle: &AppHandle, shortcut: Shortcut) {
tauri_app_handle
fn _register_shortcut_upon_start(app: &App, shortcut: Shortcut) {
let handler = app.app_handle();
handler
.plugin(
tauri_plugin_global_shortcut::Builder::new()
.with_handler(move |app, scut, event| {
@@ -145,14 +147,11 @@ fn _register_shortcut_upon_start(tauri_app_handle: &AppHandle, shortcut: Shortcu
.build(),
)
.unwrap();
tauri_app_handle
.global_shortcut()
.register(shortcut)
.unwrap();
app.global_shortcut().register(shortcut).unwrap();
}
/// Helper function to get the stored global shortcut, as a string.
pub fn _get_shortcut(app: &AppHandle) -> String {
pub fn _get_shortcut<R: Runtime>(app: &AppHandle<R>) -> String {
let store = app
.get_store(COCO_TAURI_STORE)
.expect("store should be loaded or created");

View File

@@ -4,6 +4,7 @@
//! 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)]
@@ -22,10 +23,6 @@ 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;
@@ -41,13 +38,16 @@ impl std::str::FromStr for Lang {
/// Cache the language config in memory.
static APP_LANG: RwLock<Option<Lang>> = RwLock::const_new(None);
/// Update the in-memory cached `APP_LANG` config.
/// Frontend code uses this interface to update the in-memory cached `APP_LANG` config.
#[named]
#[tauri::command]
pub(crate) async fn update_app_lang(lang: String) {
let app_lang = lang.parse::<Lang>().unwrap_or_else(|e| {
panic!(
"invalid argument [{}], could not parse it to [struct Lang], parsing error [{}]",
lang, e
"frontend code passes an invalid argument [{}] to interface [{}], parsing error [{}]",
lang,
function_name!(),
e
)
});

View File

@@ -50,7 +50,7 @@ pub(crate) enum FileType {
Unknown,
}
fn get_file_type(path: &str) -> FileType {
async 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,13 +167,8 @@ fn type_to_icon(ty: FileType) -> &'static str {
}
}
/// 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)
let ty = get_file_type(path.as_str()).await;
type_to_icon(ty)
}

View File

@@ -1,20 +1,17 @@
pub(crate) mod app_lang;
pub(crate) mod file;
pub(crate) mod path;
pub(crate) mod platform;
pub(crate) mod prevent_default;
pub(crate) mod system_lang;
pub(crate) mod updater;
use std::{path::Path, process::Command};
use tauri::AppHandle;
use tauri::{AppHandle, Runtime};
use tauri_plugin_shell::ShellExt;
/// We use this env variable to determine the DE on Linux.
const XDG_CURRENT_DESKTOP: &str = "XDG_CURRENT_DESKTOP";
#[derive(Debug, PartialEq)]
pub(crate) enum LinuxDesktopEnvironment {
enum LinuxDesktopEnvironment {
Gnome,
Kde,
Unsupported { xdg_current_desktop: String },
@@ -66,7 +63,7 @@ impl LinuxDesktopEnvironment {
}
/// None means that it is likely that we do not have a desktop environment.
pub(crate) fn get_linux_desktop_environment() -> Option<LinuxDesktopEnvironment> {
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);
@@ -91,7 +88,7 @@ pub(crate) fn get_linux_desktop_environment() -> Option<LinuxDesktopEnvironment>
//
// tauri_plugin_shell::open() is deprecated, but we still use it.
#[allow(deprecated)]
pub async fn open(app_handle: AppHandle, path: String) -> Result<(), String> {
pub async fn open<R: Runtime>(app_handle: AppHandle<R>, path: String) -> Result<(), String> {
if cfg!(target_os = "linux") {
let borrowed_path = Path::new(&path);
if let Some(file_extension) = borrowed_path.extension() {

View File

@@ -1,12 +0,0 @@
#[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")
}

View File

@@ -1,22 +1,8 @@
use derive_more::Display;
use serde::{Deserialize, Serialize};
use std::borrow::Cow;
use strum::EnumCount;
use strum::VariantArray;
#[derive(
Debug,
Deserialize,
Serialize,
Copy,
Clone,
Hash,
PartialEq,
Eq,
Display,
EnumCount,
VariantArray,
)]
#[derive(Debug, Deserialize, Serialize, Copy, Clone, Hash, PartialEq, Eq, Display)]
#[serde(rename_all(serialize = "lowercase", deserialize = "lowercase"))]
pub(crate) enum Platform {
#[display("macOS")]
@@ -32,7 +18,7 @@ impl Platform {
pub(crate) fn current() -> Platform {
let os_str = std::env::consts::OS;
serde_plain::from_str(os_str).unwrap_or_else(|_e| {
panic!("std::env::consts::OS is [{}], which is not a valid value for [enum Platform], valid values: {:?}", os_str, Self::VARIANTS.iter().map(|platform|platform.to_string()).collect::<Vec<String>>());
panic!("std::env::consts::OS is [{}], which is not a valid value for [enum Platform], valid values: ['macos', 'linux', 'windows']", os_str)
})
}
@@ -45,16 +31,4 @@ impl Platform {
Self::Linux => Cow::Owned(sysinfo::System::distribution_id()),
}
}
/// Returns the number of platforms supported by Coco.
//
// a.k.a., the number of this enum's variants.
pub(crate) fn num_of_supported_platforms() -> usize {
Platform::COUNT
}
/// Returns a set that contains all the platforms.
pub(crate) fn all() -> std::collections::HashSet<Self> {
Platform::VARIANTS.into_iter().copied().collect()
}
}

View File

@@ -1,13 +0,0 @@
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()
}

View File

@@ -1,14 +0,0 @@
/// 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('-', "_")
}

View File

@@ -1,4 +1,5 @@
{
"identifier": "rs.coco.app",
"bundle": {
"macOS": {
"entitlements": "./Entitlements.plist",
@@ -7,4 +8,4 @@
}
}
}
}
}

View File

@@ -126,9 +126,11 @@
"https://release.infinilabs.com/coco/app/.latest.json?target={{target}}&arch={{arch}}&current_version={{current_version}}"
]
},
"websocket": {},
"shell": {},
"globalShortcut": {},
"deep-link": {
"schema": "coco",
"mobile": [
{
"host": "app.infini.cloud",

View File

@@ -0,0 +1,8 @@
{
"identifier": "rs.coco.app",
"bundle": {
"macOS": {
"entitlements": "./Entitlements.plist"
}
}
}

View File

@@ -86,7 +86,6 @@ export const Get = <T>(
} else {
res = result?.data as FcResponse<T>;
}
resolve([null, res as FcResponse<T>]);
})
.catch((err) => {
@@ -104,7 +103,7 @@ export const Post = <T>(
return new Promise((resolve) => {
const appStore = JSON.parse(localStorage.getItem("app-store") || "{}");
let baseURL = appStore.state?.endpoint_http;
let baseURL = appStore.state?.endpoint_http
if (!baseURL || baseURL === "undefined") {
baseURL = "";
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,4 +1,5 @@
import { invoke } from "@tauri-apps/api/core";
import { emit } from "@tauri-apps/api/event";
import {
Server,
@@ -7,7 +8,7 @@ import {
GetResponse,
UploadAttachmentPayload,
UploadAttachmentResponse,
GetAttachmentByIdsPayload,
GetAttachmentPayload,
GetAttachmentResponse,
DeleteAttachmentPayload,
TranscriptionPayload,
@@ -15,10 +16,25 @@ import {
MultiSourceQueryResponse,
} from "@/types/commands";
import { useAppStore } from "@/stores/appStore";
import {
getCurrentWindowService,
handleLogout,
} from "@/commands/windowService";
import { useAuthStore } from "@/stores/authStore";
import { useConnectStore } from "@/stores/connectStore";
export function handleLogout(serverId?: string) {
const setIsCurrentLogin = useAuthStore.getState().setIsCurrentLogin;
const { currentService, setCurrentService, serverList, setServerList } =
useConnectStore.getState();
const id = serverId || currentService?.id;
if (!id) return;
setIsCurrentLogin(false);
emit("login_or_logout", false);
if (currentService?.id === id) {
setCurrentService({ ...currentService, profile: null });
}
const updatedServerList = serverList.map((server) =>
server.id === id ? { ...server, profile: null } : server
);
setServerList(updatedServerList);
}
// Endpoints that don't require authentication
const WHITELIST_SERVERS = [
@@ -38,9 +54,15 @@ async function invokeWithErrorHandler<T>(
command: string,
args?: Record<string, any>
): Promise<T> {
const service = await getCurrentWindowService();
const isCurrentLogin = useAuthStore.getState().isCurrentLogin;
const currentService = useConnectStore.getState().currentService;
if (!WHITELIST_SERVERS.includes(command) && !service?.profile) {
// Not logged in
console.log(command, isCurrentLogin, currentService?.profile);
if (
!WHITELIST_SERVERS.includes(command) &&
(!isCurrentLogin || !currentService?.profile)
) {
console.error("This command requires authentication");
throw new Error("This command requires authentication");
}
@@ -67,18 +89,6 @@ async function invokeWithErrorHandler<T>(
}
}
// Server Data log
let parsedResult = result;
let logData = result;
if (typeof result === "string") {
parsedResult = JSON.parse(result);
logData = parsedResult;
}
infoLog({
username: "@/commands/servers.ts",
logName: command,
})(logData);
return result;
} catch (error: any) {
const errorMessage = error || "Command execution failed";
@@ -162,6 +172,14 @@ export function mcp_server_search({
return invokeWithErrorHandler(`mcp_server_search`, { id, queryParams });
}
export function connect_to_server(id: string, clientId: string): Promise<void> {
return invokeWithErrorHandler(`connect_to_server`, { id, clientId });
}
export function disconnect(clientId: string): Promise<void> {
return invokeWithErrorHandler(`disconnect`, { clientId });
}
export function chat_history({
serverId,
from = 0,
@@ -242,40 +260,76 @@ export function cancel_session_chat({
});
}
export function new_chat({
serverId,
websocketId,
message,
queryParams,
}: {
serverId: string;
websocketId: string;
message: string;
queryParams?: Record<string, any>;
}): Promise<GetResponse> {
return invokeWithErrorHandler(`new_chat`, {
serverId,
websocketId,
message,
queryParams,
});
}
export function chat_create({
serverId,
message,
attachments,
queryParams,
clientId,
}: {
serverId: string;
message: string;
attachments: string[];
queryParams?: Record<string, any>;
clientId: string;
}): Promise<GetResponse> {
return invokeWithErrorHandler(`chat_create`, {
serverId,
message,
attachments,
queryParams,
clientId,
});
}
export function send_message({
serverId,
websocketId,
sessionId,
message,
queryParams,
}: {
serverId: string;
websocketId: string;
sessionId: string;
message: string;
queryParams?: Record<string, any>;
}): Promise<string> {
return invokeWithErrorHandler(`send_message`, {
serverId,
websocketId,
sessionId,
message,
queryParams,
});
}
export function chat_chat({
serverId,
sessionId,
message,
attachments,
queryParams,
clientId,
}: {
serverId: string;
sessionId: string;
message: string;
attachments: string[];
queryParams?: Record<string, any>;
clientId: string;
}): Promise<string> {
@@ -283,7 +337,6 @@ export function chat_chat({
serverId,
sessionId,
message,
attachments,
queryParams,
clientId,
});
@@ -338,13 +391,10 @@ export const upload_attachment = async (payload: UploadAttachmentPayload) => {
}
};
export const get_attachment_by_ids = (payload: GetAttachmentByIdsPayload) => {
return invokeWithErrorHandler<GetAttachmentResponse>(
"get_attachment_by_ids",
{
...payload,
}
);
export const get_attachment = (payload: GetAttachmentPayload) => {
return invokeWithErrorHandler<GetAttachmentResponse>("get_attachment", {
...payload,
});
};
export const delete_attachment = (payload: DeleteAttachmentPayload) => {
@@ -370,4 +420,4 @@ export const query_coco_fusion = (payload: {
export const get_app_search_source = () => {
return invokeWithErrorHandler<void>("get_app_search_source");
};
};

View File

@@ -34,4 +34,8 @@ 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');
}

View File

@@ -1,73 +0,0 @@
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);
}

View File

@@ -25,7 +25,7 @@ export const AssistantFetcher = ({
query?: string;
}) => {
try {
if (await unrequitable()) {
if (unrequitable()) {
return {
total: 0,
list: [],
@@ -57,6 +57,8 @@ export const AssistantFetcher = ({
let assistantList = response?.hits?.hits ?? [];
console.log("assistantList", assistantList);
if (
!currentAssistant?._id ||
currentService?.id !== lastServerId.current

View File

@@ -1,182 +0,0 @@
import { FC, useEffect, useMemo } from "react";
import { X } from "lucide-react";
import { useAsyncEffect } from "ahooks";
import { useTranslation } from "react-i18next";
import { useChatStore, UploadAttachments } from "@/stores/chatStore";
import { useConnectStore } from "@/stores/connectStore";
import platformAdapter from "@/utils/platformAdapter";
import Tooltip2 from "../Common/Tooltip2";
import FileIcon from "../Common/Icons/FileIcon";
import { filesize } from "@/utils";
const AttachmentList = () => {
const { uploadAttachments, setUploadAttachments } = useChatStore();
const { currentService } = useConnectStore();
const serverId = useMemo(() => {
return currentService.id;
}, [currentService]);
useEffect(() => {
return () => {
setUploadAttachments([]);
};
}, []);
const uploadAttachment = async (data: UploadAttachments) => {
const { uploading, uploaded, uploadFailed, path } = data;
if (uploading || uploaded || uploadFailed) return;
const { uploadAttachments } = useChatStore.getState();
const matched = uploadAttachments.find((item) => item.id === data.id);
if (matched) {
matched.uploading = true;
setUploadAttachments(uploadAttachments);
}
try {
const attachmentIds: any = await platformAdapter.commands(
"upload_attachment",
{
serverId,
filePaths: [path],
}
);
if (!attachmentIds) {
throw new Error("Failed to get attachment id");
} else {
Object.assign(data, {
uploaded: true,
attachmentId: attachmentIds[0],
});
}
} catch (error) {
Object.assign(data, {
uploadFailed: true,
failedMessage: String(error),
});
} finally {
Object.assign(data, {
uploading: false,
});
setUploadAttachments(uploadAttachments);
}
};
useAsyncEffect(async () => {
if (uploadAttachments.length === 0) return;
for (const item of uploadAttachments) {
uploadAttachment(item);
}
}, [uploadAttachments]);
const deleteFile = async (id: string) => {
const { uploadAttachments } = useChatStore.getState();
const matched = uploadAttachments.find((item) => item.id === id);
if (!matched) return;
const { uploadFailed, attachmentId } = matched;
setUploadAttachments(uploadAttachments.filter((file) => file.id !== id));
if (uploadFailed) return;
platformAdapter.commands("delete_attachment", {
serverId,
id: attachmentId,
});
};
return (
<div className="flex flex-wrap gap-y-2 -mx-1 text-sm">
{uploadAttachments.map((file) => {
return (
<AttachmentItem
key={file.id}
{...file}
deletable
onDelete={deleteFile}
/>
);
})}
</div>
);
};
interface AttachmentItemProps extends UploadAttachments {
deletable?: boolean;
onDelete?: (id: string) => void;
}
export const AttachmentItem: FC<AttachmentItemProps> = (props) => {
const {
id,
name,
path,
extname,
size,
uploaded,
attachmentId,
uploadFailed,
failedMessage,
deletable,
onDelete,
} = props;
const { t } = useTranslation();
return (
<div key={id} className="w-1/3 px-1">
<div className="relative group flex items-center gap-1 p-1 rounded-[4px] bg-[#dedede] dark:bg-[#202126]">
{(uploadFailed || attachmentId) && deletable && (
<div
className="absolute flex justify-center items-center size-[14px] bg-red-600 top-0 right-0 rounded-full cursor-pointer translate-x-[5px] -translate-y-[5px] transition opacity-0 group-hover:opacity-100 "
onClick={() => {
onDelete?.(id);
}}
>
<X className="size-[10px] text-white" />
</div>
)}
<FileIcon path={path} />
<div className="flex flex-col justify-between overflow-hidden">
<div className="truncate text-sm text-[#333333] dark:text-[#D8D8D8]">
{name}
</div>
<div className="text-xs">
{uploadFailed && failedMessage ? (
<Tooltip2 content={failedMessage}>
<span className="text-red-500">Upload Failed</span>
</Tooltip2>
) : (
<div className="text-[#999]">
{uploaded ? (
<div className="flex gap-2">
{extname && <span>{extname}</span>}
<span>{filesize(size)}</span>
</div>
) : (
<span>{t("assistant.fileList.uploading")}</span>
)}
</div>
)}
</div>
</div>
</div>
</div>
);
};
export default AttachmentList;

View File

@@ -43,13 +43,8 @@ interface ChatAIProps {
instanceId?: string;
}
export interface SendMessageParams {
message?: string;
attachments?: string[];
}
export interface ChatAIRef {
init: (params: SendMessageParams) => void;
init: (value: string) => void;
cancelChat: () => void;
clearChat: () => void;
}
@@ -193,7 +188,7 @@ const ChatAI = memo(
isDeepThinkActive,
isMCPActive,
changeInput,
showChatHistory
showChatHistory,
);
const { dealMsg } = useMessageHandler(
@@ -230,10 +225,10 @@ const ChatAI = memo(
}, [activeChat, chatClose]);
const init = useCallback(
async (params: SendMessageParams) => {
async (value: string) => {
try {
//console.log("init", curChatEnd, activeChat?._id);
if (isTauri && !isCurrentLogin) {
if (!isCurrentLogin) {
addError("Please login to continue chatting");
return;
}
@@ -242,9 +237,9 @@ const ChatAI = memo(
return;
}
if (!activeChat?._id) {
await createNewChat(params);
await createNewChat(value);
} else {
await handleSendMessage(activeChat, params);
await handleSendMessage(value, activeChat);
}
} catch (error) {
console.error("Failed to initialize chat:", error);
@@ -290,10 +285,7 @@ const ChatAI = memo(
if (updatedChats.length > 0) {
setActiveChat(updatedChats[0]);
} else {
init({
message: "",
attachments: [],
});
init("");
}
}
@@ -390,7 +382,7 @@ const ChatAI = memo(
assistantIDs={assistantIDs}
/>
{!isTauri || (isTauri && isCurrentLogin) ? (
{isCurrentLogin ? (
<>
<ChatContent
activeChat={activeChat}
@@ -404,8 +396,8 @@ const ChatAI = memo(
loadingStep={loadingStep}
timedoutShow={timedoutShow}
Question={Question}
handleSendMessage={(message) =>
handleSendMessage(activeChat, { message })
handleSendMessage={(value) =>
handleSendMessage(value, activeChat)
}
getFileUrl={getFileUrl}
formatUrl={formatUrl}
@@ -418,11 +410,7 @@ const ChatAI = memo(
)}
{!activeChat?._id && !visibleStartPage && (
<PrevSuggestion
sendMessage={(message) => {
init({ message });
}}
/>
<PrevSuggestion sendMessage={init} />
)}
</div>
</>

View File

@@ -3,14 +3,13 @@ import { useTranslation } from "react-i18next";
import { ChatMessage } from "@/components/ChatMessage";
import { Greetings } from "./Greetings";
import AttachmentList from "@/components/Assistant/AttachmentList";
// import FileList from "@/components/Assistant/FileList";
import { useChatScroll } from "@/hooks/useChatScroll";
import { useChatStore } from "@/stores/chatStore";
import type { Chat, IChunkData } from "@/types/chat";
import { useConnectStore } from "@/stores/connectStore";
// import SessionFile from "./SessionFile";
import ScrollToBottom from "@/components/Common/ScrollToBottom";
import { useChatStore } from "@/stores/chatStore";
interface ChatContentProps {
activeChat?: Chat;
@@ -45,12 +44,14 @@ export const ChatContent = ({
handleSendMessage,
formatUrl,
}: ChatContentProps) => {
const { currentSessionId, setCurrentSessionId } = useConnectStore();
// const sessionId = useConnectStore((state) => state.currentSessionId);
const setCurrentSessionId = useConnectStore((state) => {
return state.setCurrentSessionId;
});
const { t } = useTranslation();
const { uploadAttachments } = useChatStore();
// const uploadFiles = useChatStore((state) => state.uploadFiles);
const messagesEndRef = useRef<HTMLDivElement>(null);
const { scrollToBottom } = useChatScroll(messagesEndRef);
@@ -167,13 +168,13 @@ export const ChatContent = ({
<div ref={messagesEndRef} />
</div>
{uploadAttachments.length > 0 && (
<div key={currentSessionId} className="max-h-[120px] overflow-auto p-2">
<AttachmentList />
{/* {uploadFiles.length > 0 && (
<div key={sessionId} className="max-h-[120px] overflow-auto p-2">
<FileList />
</div>
)}
)} */}
{/* {currentSessionId && <SessionFile sessionId={currentSessionId} />} */}
{/* {sessionId && <SessionFile sessionId={sessionId} />} */}
<ScrollToBottom scrollRef={scrollRef} isAtBottom={isAtBottom} />
</div>

View File

@@ -12,6 +12,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";
interface ChatHeaderProps {
clearChat: () => void;
@@ -34,22 +35,12 @@ export function ChatHeader({
showChatHistory = true,
assistantIDs,
}: ChatHeaderProps) {
const { isPinned, setIsPinned, isTauri } = useAppStore();
const { isTauri } = useAppStore();
const { isPinned, togglePin } = useTogglePin();
const { historicalRecords, newSession, fixedWindow, external } =
useShortcutsStore();
const togglePin = async () => {
try {
const { isPinned } = useAppStore.getState();
setIsPinned(!isPinned);
} catch (err) {
console.error("Failed to toggle window pin state:", err);
setIsPinned(isPinned);
}
};
return (
<header
className="flex items-center justify-between py-2 px-3 select-none"

View File

@@ -0,0 +1,142 @@
import { useEffect, useMemo } from "react";
import { filesize } from "filesize";
import { X } from "lucide-react";
import { useAsyncEffect } from "ahooks";
import { useTranslation } from "react-i18next";
import { useChatStore, UploadFile } from "@/stores/chatStore";
import { useConnectStore } from "@/stores/connectStore";
import platformAdapter from "@/utils/platformAdapter";
import Tooltip2 from "../Common/Tooltip2";
import FileIcon from "../Common/Icons/FileIcon";
const FileList = () => {
const { t } = useTranslation();
const { uploadFiles, setUploadFiles } = useChatStore();
const { currentService } = useConnectStore();
const serverId = useMemo(() => {
return currentService.id;
}, [currentService]);
useEffect(() => {
return () => {
setUploadFiles([]);
};
}, []);
useAsyncEffect(async () => {
if (uploadFiles.length === 0) return;
for await (const item of uploadFiles) {
const { uploaded, path } = item;
if (uploaded) continue;
try {
const attachmentIds: any = await platformAdapter.commands(
"upload_attachment",
{
serverId,
filePaths: [path],
}
);
if (!attachmentIds) {
throw new Error("Failed to get attachment id");
} else {
Object.assign(item, {
uploaded: true,
attachmentId: attachmentIds[0],
});
}
setUploadFiles(uploadFiles);
} catch (error) {
Object.assign(item, {
uploadFailed: true,
failedMessage: String(error),
});
}
}
}, [uploadFiles]);
const deleteFile = async (file: UploadFile) => {
const { id, uploadFailed, attachmentId } = file;
setUploadFiles(uploadFiles.filter((file) => file.id !== id));
if (uploadFailed) return;
platformAdapter.commands("delete_attachment", {
serverId,
id: attachmentId,
});
};
return (
<div className="flex flex-wrap gap-y-2 -mx-1 text-sm">
{uploadFiles.map((file) => {
const {
id,
name,
path,
extname,
size,
uploaded,
attachmentId,
uploadFailed,
failedMessage,
} = file;
return (
<div key={id} className="w-1/3 px-1">
<div className="relative group flex items-center gap-1 p-1 rounded-[4px] bg-[#dedede] dark:bg-[#202126]">
{(uploadFailed || attachmentId) && (
<div
className="absolute flex justify-center items-center size-[14px] bg-red-600 top-0 right-0 rounded-full cursor-pointer translate-x-[5px] -translate-y-[5px] transition opacity-0 group-hover:opacity-100 "
onClick={() => {
deleteFile(file);
}}
>
<X className="size-[10px] text-white" />
</div>
)}
<FileIcon path={path} />
<div className="flex flex-col justify-between overflow-hidden">
<div className="truncate text-[#333333] dark:text-[#D8D8D8]">
{name}
</div>
<div className="text-xs">
{uploadFailed && failedMessage ? (
<Tooltip2 content={failedMessage}>
<span className="text-red-500">Upload Failed</span>
</Tooltip2>
) : (
<div className="text-[#999]">
{uploaded ? (
<div className="flex gap-2">
{extname && <span>{extname}</span>}
<span>
{filesize(size, { standard: "jedec", spacer: "" })}
</span>
</div>
) : (
<span>{t("assistant.fileList.uploading")}</span>
)}
</div>
)}
</div>
</div>
</div>
</div>
);
})}
</div>
);
};
export default FileList;

View File

@@ -17,8 +17,6 @@ import { Server as IServer } from "@/types/server";
import StatusIndicator from "@/components/Cloud/StatusIndicator";
import { useAuthStore } from "@/stores/authStore";
import { useSearchStore } from "@/stores/searchStore";
import { useServers } from "@/hooks/useServers";
import { getCurrentWindowService, setCurrentWindowService } from "@/commands/windowService";
interface ServerListProps {
clearChat: () => void;
@@ -27,20 +25,17 @@ interface ServerListProps {
export function ServerList({ clearChat }: ServerListProps) {
const { t } = useTranslation();
const isCurrentLogin = useAuthStore((state) => state.isCurrentLogin);
const setIsCurrentLogin = useAuthStore((state) => state.setIsCurrentLogin);
const serviceListShortcut = useShortcutsStore(
(state) => state.serviceListShortcut
);
const serviceList = useShortcutsStore((state) => state.serviceList);
const setEndpoint = useAppStore((state) => state.setEndpoint);
const setCurrentService = useConnectStore((state) => state.setCurrentService);
const isTauri = useAppStore((state) => state.isTauri);
const currentService = useConnectStore((state) => state.currentService);
const cloudSelectService = useConnectStore((state) => {
return state.cloudSelectService;
});
const { setMessages } = useChatStore();
const [list, setList] = useState<IServer[]>([]);
const [serverList, setServerList] = useState<IServer[]>([]);
const [isRefreshing, setIsRefreshing] = useState(false);
const [highlightId, setHighlightId] = useState<string>("");
@@ -54,49 +49,44 @@ export function ServerList({ clearChat }: ServerListProps) {
const popoverRef = useRef<HTMLDivElement>(null);
const serverListButtonRef = useRef<HTMLButtonElement>(null);
const { refreshServerList } = useServers();
const serverList = useConnectStore((state) => state.serverList);
const fetchServers = useCallback(
async (resetSelection: boolean) => {
platformAdapter.commands("list_coco_servers").then((res: any) => {
console.log("list_coco_servers", res);
if (!Array.isArray(res)) {
// If res is not an array, it might be an error message or something else.
// Log it and don't proceed.
// console.log("list_coco_servers did not return an array:", res);
setServerList([]); // Clear the list or handle as appropriate
return;
}
const enabledServers = (res as IServer[])?.filter(
(server) => server.enabled && server.available
);
const switchServer = async (server: IServer) => {
if (!server) return;
try {
// Switch UI first, then switch server connection
await setCurrentWindowService(server);
setEndpoint(server.endpoint);
setMessages(""); // Clear previous messages
clearChat();
//
if (!server.public && !server.profile) {
setIsCurrentLogin(false);
return;
}
//
setIsCurrentLogin(true);
} catch (error) {
console.error("switchServer:", error);
}
};
setServerList(enabledServers);
const fetchServers = useCallback(async () => {
const service = await getCurrentWindowService();
if (resetSelection && enabledServers.length > 0) {
const currentServiceExists = enabledServers.find(
(server) => server.id === currentService?.id
);
const enabledServers = serverList.filter(
(server) => server.enabled && server.available
);
setList(enabledServers);
if (enabledServers.length > 0) {
const serviceExists = enabledServers.find((server) => {
return server.id === service?.id;
if (currentServiceExists) {
switchServer(currentServiceExists);
} else {
switchServer(enabledServers[enabledServers.length - 1]);
}
}
});
},
[currentService?.id]
);
if (serviceExists) {
switchServer(serviceExists);
} else {
switchServer(enabledServers[enabledServers.length - 1]);
}
}
}, [currentService?.id, cloudSelectService?.id, serverList]);
useEffect(() => {
if (!isTauri) return;
fetchServers(true);
}, [currentService?.enabled]);
useEffect(() => {
if (!askAiServerId || serverList.length === 0) return;
@@ -114,12 +104,25 @@ export function ServerList({ clearChat }: ServerListProps) {
useEffect(() => {
if (!isTauri) return;
fetchServers();
}, [serverList]);
fetchServers(true);
const unlisten = platformAdapter.listenEvent("login_or_logout", (event) => {
//console.log("Login or Logout:", currentService, event.payload);
if (event.payload !== isCurrentLogin) {
setIsCurrentLogin(!!event.payload);
}
fetchServers(true);
});
return () => {
// Cleanup logic if needed
unlisten.then((fn) => fn());
};
}, []);
const handleRefresh = async () => {
setIsRefreshing(true);
await refreshServerList();
await fetchServers(false);
setTimeout(() => setIsRefreshing(false), 1000);
};
@@ -127,10 +130,29 @@ export function ServerList({ clearChat }: ServerListProps) {
platformAdapter.emitEvent("open_settings", "connect");
};
const switchServer = async (server: IServer) => {
if (!server) return;
try {
// Switch UI first, then switch server connection
setCurrentService(server);
setEndpoint(server.endpoint);
setMessages(""); // Clear previous messages
clearChat();
//
if (!server.public && !server.profile) {
setIsCurrentLogin(false);
return;
}
//
setIsCurrentLogin(true);
} catch (error) {
console.error("switchServer:", error);
}
};
useKeyPress(
["uparrow", "downarrow", "enter"],
async (event, key) => {
const service = await getCurrentWindowService();
(event, key) => {
const isClose = isNil(serverListButtonRef.current?.dataset["open"]);
const length = serverList.length;
@@ -140,7 +162,9 @@ export function ServerList({ clearChat }: ServerListProps) {
event.preventDefault();
const currentIndex = serverList.findIndex((server) => {
return server.id === (highlightId === "" ? service?.id : highlightId);
return (
server.id === (highlightId === "" ? currentService?.id : highlightId)
);
});
let nextIndex = currentIndex;
@@ -173,7 +197,7 @@ export function ServerList({ clearChat }: ServerListProps) {
<Popover ref={popoverRef} className="relative">
<PopoverButton ref={serverListButtonRef} className="flex items-center">
<VisibleKey
shortcut={serviceListShortcut}
shortcut={serviceList}
onKeyPress={() => {
serverListButtonRef.current?.click();
}}
@@ -216,8 +240,8 @@ export function ServerList({ clearChat }: ServerListProps) {
</div>
</div>
<div className="space-y-1">
{list.length > 0 ? (
list.map((server) => (
{serverList.length > 0 ? (
serverList.map((server) => (
<div
key={server.id}
onClick={() => switchServer(server)}

View File

@@ -1,4 +1,5 @@
import clsx from "clsx";
import { filesize } from "filesize";
import { Files, Trash2, X } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -9,7 +10,6 @@ import { AttachmentHit } from "@/types/commands";
import { useAppStore } from "@/stores/appStore";
import platformAdapter from "@/utils/platformAdapter";
import FileIcon from "../Common/Icons/FileIcon";
import { filesize } from "@/utils";
interface SessionFileProps {
sessionId: string;
@@ -39,13 +39,10 @@ const SessionFile = (props: SessionFileProps) => {
if (isTauri) {
console.log("sessionId", sessionId);
const response: any = await platformAdapter.commands(
"get_attachment_by_ids",
{
serverId,
sessionId,
}
);
const response: any = await platformAdapter.commands("get_attachment", {
serverId,
sessionId,
});
setUploadedFiles(response?.hits?.hits ?? []);
} else {
@@ -148,7 +145,9 @@ const SessionFile = (props: SessionFileProps) => {
</div>
<div className="text-xs text-[#999]">
{icon && <span className="pr-2">{icon}</span>}
<span>{filesize(size)}</span>
<span>
{filesize(size, { standard: "jedec", spacer: "" })}
</span>
</div>
</div>
</div>

View File

@@ -54,7 +54,7 @@ const Splash = ({ assistantIDs = [], startPage }: SplashProps) => {
let response: any;
if (isTauri) {
if (await unrequitable()) {
if (unrequitable()) {
return setVisibleStartPage(false);
}
@@ -72,8 +72,6 @@ const Splash = ({ assistantIDs = [], startPage }: SplashProps) => {
setSettings(response);
};
console.log("currentService", currentService);
useEffect(() => {
getSettings();
fetchData();

View File

@@ -1,7 +1,6 @@
import { useEffect, useRef } from "react";
import dayjs from "dayjs";
import durationPlugin from "dayjs/plugin/duration";
import { nanoid } from "nanoid";
import { useThemeStore } from "@/stores/themeStore";
import loadingLight from "@/assets/images/ReadAloud/loading-light.png";
@@ -19,6 +18,7 @@ import closeDark from "@/assets/images/ReadAloud/close-dark.png";
import { useConnectStore } from "@/stores/connectStore";
import platformAdapter from "@/utils/platformAdapter";
import { useStreamAudio } from "@/hooks/useStreamAudio";
import { nanoid } from "nanoid";
import { useChatStore } from "@/stores/chatStore";
dayjs.extend(durationPlugin);

View File

@@ -82,6 +82,8 @@ const AudioRecording: FC<AudioRecordingProps> = (props) => {
}
);
console.log("response", response);
const text = response?.results
.flatMap((item: any) => item?.transcription?.transcripts)
.map((item: any) => item?.text?.replace(/<\|[\/\w]+\|>/g, ""))
@@ -159,7 +161,7 @@ const AudioRecording: FC<AudioRecordingProps> = (props) => {
<>
<div
className={clsx(
"min-w-6 h-6 flex items-center justify-center hover:bg-gray-50 dark:hover:bg-gray-700 rounded-full transition cursor-pointer",
"size-6 flex items-center justify-center hover:bg-gray-50 dark:hover:bg-gray-700 rounded-full transition cursor-pointer",
{
hidden: state.audioDevices.length === 0,
}

View File

@@ -67,7 +67,7 @@ export const MessageActions = ({
};
const handleSpeak = async () => {
if (await isDefaultServer()) {
if (isDefaultServer()) {
return setSynthesizeItem({ id, content });
}

View File

@@ -1,28 +1,17 @@
import { FC, useState } from "react";
import { useState } from "react";
import clsx from "clsx";
import { CopyButton } from "@/components/Common/CopyButton";
import { useAsyncEffect } from "ahooks";
import platformAdapter from "@/utils/platformAdapter";
import { useConnectStore } from "@/stores/connectStore";
import { AttachmentItem } from "../Assistant/AttachmentList";
import { useAppStore } from "@/stores/appStore";
interface UserMessageProps {
message: string;
attachments: string[];
messageContent: string;
}
export const UserMessage: FC<UserMessageProps> = (props) => {
const { message, attachments } = props;
export const UserMessage = ({ messageContent }: UserMessageProps) => {
const [showCopyButton, setShowCopyButton] = useState(false);
const { currentService } = useConnectStore();
const [attachmentData, setAttachmentData] = useState<any[]>([]);
const { addError } = useAppStore();
const handleDoubleClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (typeof window !== "undefined" && typeof document !== "undefined") {
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
const selection = window.getSelection();
const range = document.createRange();
@@ -32,81 +21,31 @@ export const UserMessage: FC<UserMessageProps> = (props) => {
selection.removeAllRanges();
selection.addRange(range);
} catch (error) {
console.error("Selection failed:", error);
console.error('Selection failed:', error);
}
}
}
};
useAsyncEffect(async () => {
try {
if (attachments.length === 0) return;
const result: any = await platformAdapter.commands(
"get_attachment_by_ids",
{
serverId: currentService.id,
attachments,
}
);
setAttachmentData(result?.hits?.hits);
} catch (error) {
addError(String(error));
}
}, [attachments]);
return (
<>
{message && (
<div
className="flex gap-1 items-center justify-end"
onMouseEnter={() => setShowCopyButton(true)}
onMouseLeave={() => setShowCopyButton(false)}
>
<div
className={clsx("size-6 transition", {
"opacity-0": !showCopyButton,
})}
>
<CopyButton textToCopy={message} />
</div>
<div
className="max-w-[85%] overflow-auto text-left px-3 py-2 bg-white dark:bg-[#202126] rounded-xl border border-black/12 dark:border-black/15 font-normal text-sm text-[#333333] dark:text-[#D8D8D8] cursor-pointer user-select-text whitespace-pre-wrap"
onDoubleClick={handleDoubleClick}
>
{message}
</div>
</div>
)}
{attachmentData && (
<div
className={clsx("flex justify-end flex-wrap gap-y-2 w-full", {
"mt-3": message,
})}
>
{attachmentData.map((item) => {
const { id, name, size, icon } = item._source;
return (
<AttachmentItem
{...item._source}
key={id}
uploading={false}
uploaded
id={id}
extname={icon}
attachmentId={id}
name={name}
path={name}
size={size}
deletable={false}
/>
);
})}
</div>
)}
</>
<div
className="max-w-full flex gap-1 items-center justify-end"
onMouseEnter={() => setShowCopyButton(true)}
onMouseLeave={() => setShowCopyButton(false)}
>
<div
className={clsx("size-6 transition", {
"opacity-0": !showCopyButton,
})}
>
<CopyButton textToCopy={messageContent} />
</div>
<div
className="max-w-[85%] overflow-auto text-left px-3 py-2 bg-white dark:bg-[#202126] rounded-xl border border-black/12 dark:border-black/15 font-normal text-sm text-[#333333] dark:text-[#D8D8D8] cursor-pointer user-select-text whitespace-pre-wrap"
onDoubleClick={handleDoubleClick}
>
{messageContent}
</div>
</div>
);
};

View File

@@ -89,7 +89,6 @@ export const ChatMessage = memo(function ChatMessage({
]);
const messageContent = message?._source?.message || "";
const attachments = message?._source?.attachments ?? [];
const details = message?._source?.details || [];
const question = message?._source?.question || "";
@@ -104,7 +103,7 @@ export const ChatMessage = memo(function ChatMessage({
const renderContent = () => {
if (!isAssistant) {
return <UserMessage message={messageContent} attachments={attachments} />;
return <UserMessage messageContent={messageContent} />;
}
return (

View File

@@ -1,6 +1,5 @@
import { useEffect, useRef, useState, useCallback } from "react";
import { isEqual } from "lodash-es";
import { usePrevious } from "ahooks";
import { emit } from "@tauri-apps/api/event";
import { DataSourcesList } from "./DataSourcesList";
import { Sidebar } from "./Sidebar";
@@ -9,8 +8,6 @@ import { useAppStore } from "@/stores/appStore";
import { useConnectStore } from "@/stores/connectStore";
import ServiceInfo from "./ServiceInfo";
import ServiceAuth from "./ServiceAuth";
import type { Server } from "@/types/server";
import { useServers } from "@/hooks/useServers";
import platformAdapter from "@/utils/platformAdapter";
export default function Cloud() {
@@ -20,72 +17,100 @@ export default function Cloud() {
const [isConnect, setIsConnect] = useState(true);
const {
cloudSelectService,
setCloudSelectService,
serverList,
setServerList,
} = useConnectStore();
const prevServerList = usePrevious(serverList);
const { currentService, setCurrentService, serverList, setServerList } =
useConnectStore();
const [refreshLoading, setRefreshLoading] = useState(false);
const { addServer, refreshServerList } = useServers();
// fetch the servers
useEffect(() => {
if (isEqual(prevServerList, serverList)) return;
fetchServers();
}, [serverList]);
fetchServers(true);
}, []);
useEffect(() => {
// console.log("currentService", currentService);
setRefreshLoading(false);
setIsConnect(true);
}, [cloudSelectService?.id]);
}, [JSON.stringify(currentService)]);
const fetchServers = useCallback(async () => {
let { serverList } = useConnectStore.getState();
if (errors.length > 0) {
serverList = serverList.map((item: Server) => {
if (item.id === cloudSelectService?.id) {
return {
...item,
health: {
services: item.health?.services || {},
status: item.health?.status || "red",
},
};
const fetchServers = async (resetSelection: boolean) => {
platformAdapter
.commands("list_coco_servers")
.then((res: any) => {
if (errors.length > 0) {
res = (res || []).map((item: any) => {
if (item.id === currentService?.id) {
item.health = {
services: null,
status: null,
};
}
return item;
});
}
return item;
});
console.log("list_coco_servers", res);
setServerList(res);
if (resetSelection && res.length > 0) {
const matched = res.find((server: any) => {
return server.id === currentService?.id;
});
if (matched) {
setCurrentService(matched);
} else {
setCurrentService(res[res.length - 1]);
}
}
})
};
const addServer = (endpointLink: string) => {
if (!endpointLink) {
throw new Error("Endpoint is required");
}
if (
!endpointLink.startsWith("http://") &&
!endpointLink.startsWith("https://")
) {
throw new Error("Invalid Endpoint");
}
setServerList(serverList);
setRefreshLoading(true);
if (serverList.length > 0) {
const matched = serverList.find((server: any) => {
return server.id === cloudSelectService?.id;
return platformAdapter
.commands("add_coco_server", endpointLink)
.then((res: any) => {
// console.log("add_coco_server", res);
fetchServers(false).then((r) => {
console.log("fetchServers", r);
setCurrentService(res);
});
})
.finally(() => {
setRefreshLoading(false);
});
if (matched) {
setCloudSelectService(matched);
} else {
setCloudSelectService(serverList[serverList.length - 1]);
}
}
}, [errors, cloudSelectService]);
};
const refreshClick = useCallback(
async (id: string, callback?: () => void) => {
(id: string) => {
setRefreshLoading(true);
await platformAdapter.commands("refresh_coco_server_info", id);
await refreshServerList();
setRefreshLoading(false);
callback && callback();
platformAdapter
.commands("refresh_coco_server_info", id)
.then((res: any) => {
console.log("refresh_coco_server_info", id, res);
fetchServers(false).then((r) => {
console.log("fetchServers", r);
});
// update currentService
setCurrentService(res);
emit("login_or_logout", true);
})
.finally(() => {
setRefreshLoading(false);
});
},
[refreshServerList]
[fetchServers]
);
return (
@@ -102,6 +127,7 @@ export default function Cloud() {
<ServiceInfo
refreshLoading={refreshLoading}
refreshClick={refreshClick}
fetchServers={fetchServers}
/>
<ServiceAuth
@@ -109,8 +135,8 @@ export default function Cloud() {
refreshClick={refreshClick}
/>
{cloudSelectService?.profile && cloudSelectService?.available ? (
<DataSourcesList server={cloudSelectService?.id} />
{currentService?.profile && currentService?.available ? (
<DataSourcesList server={currentService?.id} />
) : null}
</div>
) : (

View File

@@ -21,7 +21,7 @@ export function Connect({ setIsConnect, onAddServer }: ConnectServiceProps) {
};
const onAddServerClick = async (endpoint: string) => {
//console.log("onAddServer", endpoint);
console.log("onAddServer", endpoint);
await onAddServer(endpoint);
setIsConnect(true);
};

View File

@@ -20,6 +20,7 @@ export function DataSourcesList({ server }: { server: string }) {
platformAdapter
.commands("get_connectors_by_server", server)
.then((res: any) => {
// console.log("get_connectors_by_server", res);
setConnectorData(res, server);
})
.finally(() => {});
@@ -28,6 +29,7 @@ export function DataSourcesList({ server }: { server: string }) {
platformAdapter
.commands("datasource_search", { id: server })
.then((res: any) => {
// console.log("datasource_search", res);
setDatasourceData(res, server);
})
.finally(() => {

Some files were not shown because too many files have changed in this diff Show More