36 Commits

Author SHA1 Message Date
ayang
534e2dddab refactor: switched routing mode to hash 2025-08-20 11:38:14 +08:00
SteveLauC
5dae5d1cc1 refactor: accept both '-' and '_' as locale str separator (#876)
I wasn't aware that both '-' (specified by the standard) and '_' (not in the
standard, but is commonly used) can be used as the tag delimiter in locale
strings[1] when I originally wrote this commit[2].

Both "zh-CN" and "zh_CN" are valid locale strings!

Since '_' is more commonly used, I thought it was the only correct form and
thus our code only accepts it.

This commit refactors the implementation to accept both.

[1]: https://stackoverflow.com/a/36752015/14092446
[2]: f5b33af7f1
2025-08-19 20:08:25 +08:00
Steve Lau
23372655ca feat: index app names in system language 2025-08-19 14:26:30 +08:00
Steve Lau
f5b33af7f1 feat: index both en/zh_CN app names and show app name in Coco app language
After this commit, we index both English and Chinese application names
so that searches in either language will work.  And the names of the
applications in search results and application list will be in the app language.

Pizza index structure has been updated, but backward compatibility is preserved
by keeping the support code for the old index field.

The changes in this commit are not macOS-specific, it applies to all supported
platforms.  Though this feature won't work on Linux and Windows until we implement
the localized app name support in the underlying applications-rs crate.
2025-08-19 14:26:30 +08:00
ayangweb
993da9a8ad refactor: improved loss after refresh (#874)
* refactor: improved loss after refresh

* refactor: update

* style: change code line
2025-08-18 18:18:49 +08:00
ayangweb
93f1024230 refactor: optimize language changes (#873)
* refactor: optimize language changes

* update
2025-08-18 15:38:13 +08:00
SteveLauC
7b5e528060 refactor: index iOS apps and macOS apps that store icon in Assets.car (#872)
Bumps the 'applications' crate to include this commit[1].  With this,
Coco now indexes iOS apps and macOS apps that store icon in Assets.car.

[1]: 814b16ea84
2025-08-15 18:41:44 +08:00
SteveLauC
1d5ba3ab07 refactor: coordinate third-party extension operations using lock (#867)
refactor: coordinate third-party extension operations using lock

During debugging 783cb73b29,
I realized that some extension operations were not synchronized and thus would
behave incorrectly under concurrency.  Though GUI apps like Coco typically
won't have concurrency.  This commit synchronizes them by putting them behind
the lock.
2025-08-13 17:36:37 +08:00
SteveLauC
f93c527561 refactor: let frontend set up backend states to avoid races (#870)
Co-authored-by: ayang <473033518@qq.com>
2025-08-13 15:33:30 +08:00
BiggerRain
6065353ac9 chore: remove log (#868) 2025-08-11 11:45:16 +08:00
SteveLauC
783cb73b29 feat: deeplink handler for install ext from store (#860)
Co-authored-by: rain9 <15911122312@163.com>
Co-authored-by: ayang <473033518@qq.com>
2025-08-05 18:08:00 +08:00
SteveLauC
ee75f0d119 feat: impl extension settings 'hide_before_open' (#862)
This commit implementes a new extension setting entry
"hide_before_open":

> Extension plugin.json

```json
{
  "name": "Screenshot",
  "settings": {
    "hide_before_open": true
  }
}
```

that, if set to true, Coco will hide the main window before opening the
extension.

Only entensions that can be opened can set their "settings" field, a
check rule is added to guarantee this.
2025-08-04 16:58:27 +08:00
SteveLauC
aaac874f2c ci: check frontend code by building it (#861)
Adds build check for our frontend code
2025-08-03 16:04:32 +08:00
SteveLauC
cd9e454991 chore: remove unused deeplink-releated rust code (#859)
Deep linking is handled on the frontend, so this commit removes the related and
unused backend code.

"src-tauri/tauri.conf.json" is also modified, field "plugins.deep-link.schema"
does not exist so I removed it as well.
2025-08-03 14:38:47 +08:00
BiggerRain
d0fc79238b build: web component build error (#858)
* build: build error

* docs: update notes
2025-08-02 11:14:56 +08:00
BiggerRain
3ed84c2318 fix: web component login state (#857)
* fix: web component login state

* docs: update notes

* build: build error
2025-08-02 10:29:23 +08:00
ayangweb
bd039398ba refactor: optimize uninstall extension (#856) 2025-08-01 18:40:58 +08:00
ayangweb
568db6aba0 feat: add extension uninstall option in settings (#855)
* feat: add extension uninstall option in settings

* docs: update changelog

* update
2025-08-01 18:28:37 +08:00
SteveLauC
2eb10933e7 refactor: pinning window won't set CanJoinAllSpaces on macOS (#854)
This commit reverts the logic introduced in
e7dd27c744:

> Pinning the main window would bring "NSWindowCollectionBehaviorCanJoinAllSpaces"
> back to make it really stay pinned across all the spaces.

Commit 6bc78b41ef (diff-b55e9c1de63ea370ce736826e4dea5685bfa3992d8dee58427337e68b71a1fc1)
did a tiny refactor to the frontend code merged in the above commit,
these changes are reverted as well.

We revert these changes because we observed an issue with window
focus, and we don't know the root cause and how to fix the issue either.

The following change is kept because we don't want to hit this NS panel
bug[1].  But if the issue still exists after this commit, it will
be removed as well.

In "src-tauri/src/setup/mac.rs":

```diff
 panel.set_collection_behaviour(
-       NSWindowCollectionBehavior::NSWindowCollectionBehaviorCanJoinAllSpaces
+       NSWindowCollectionBehavior::NSWindowCollectionBehaviorMoveToActiveSpace
```

[1]: https://github.com/ahkohd/tauri-nspanel/issues/76
2025-08-01 15:28:11 +08:00
ayangweb
5c6cf18139 chore: revert microphone permission (#852) 2025-08-01 12:53:38 +08:00
ayangweb
01c31d884a fix: fix microphone permission issue (#851) 2025-08-01 11:02:25 +08:00
ayangweb
d48d4af7d2 refactor: optimize upload shortcut display (#850) 2025-08-01 10:24:30 +08:00
ayangweb
876d14f9d9 refactor: optimize enter key display (#849) 2025-08-01 10:20:47 +08:00
ayangweb
a8e090c9be refactor: optimized sending messages (#848) 2025-08-01 09:36:06 +08:00
SteveLauC
c30df6cee0 feat: sub extension can set 'platforms' now (#847)
Before this commit, sub extensions were not allowed to set their
"platforms" field, this restriction is lifted in this commit.

By allowing this, a group extension can have sub extensions for
different platforms, here is an example (irrelavent fields are omitted
for the sake of simplicity):

```json
{
  "name": "Suspend my machine",
  "type": "Group",
  "platforms": ["macos", "windows"],
  "commands": [
    {
      "name": "Suspend macOS":
      "platforms": ["macos"],
      "action": {...}
    },
    {
      "name": "Suspend Windows":
      "platforms": ["windows"],
      "action": {...}
    }
  ]
}
```

While loading or installing extensions, incompatible sub extensions will
be filtered out by Coco, e.g., you won't see that "Suspend Windows"
command if you are on macOS.

An extra check is added in this commit to ensure a sub extensions won't
support the platforms that are incompatible with its main extension.

Even though main extensions and sub extensions can both have "platforms"
specified, the semantics of this field, when not set, differs between them.
For main extensions, it means this extension is compatible with all the
platforms supported by Coco (null == all).  For sub extensions, having it
not set implicitly says that this field has the same value as the main
extension's "platforms" field.

The primary reason behind this design is that if we choose the semantics used
by the main extension, treating null as all, all the extensions we currently
have will become invalid, because they are all macOS-only, the main extensions's
"platforms" field is "macos" and sub extensions' "platforms" is not set (null),
they will be equivalent to:

```json
{
  "name": "this is macOS-only",
  "type": "Group",
  "platforms": ["macos"],
  "commands": [
    {
      "name": "How the fxxk can this support all the platforms!"
      "platforms": ["macos", "windows", "linux"],
      "type": "Command",
      "action": {...}
    }
  ]
}
```
This hits exactly the check we mentioned earlier and will be rejected by
Coco.  If we have users installed them, the installed extensions will be
treated invalid and rejected by future Coco release, boom, we break backward
compatibility.

Also, the current design actually makes sense.  Nobody wants to repeatedly
tell Coco that all the sub extensions support macOS if this can be said only
once:

```json
{
  "name": "this is macOS-only",
  "platforms": ["macos"],
  "commands": [
    {
      "name": "This supports macOS"
      "platforms": ["macos"],
    },
    {
      "name": "This supports macOS too"
      "platforms": ["macos"],
    },
    {
      "name": "Guess what! this also supports macOS"
      "platforms": ["macos"],
    },
    {
      "name": "Come on dude, do I really to say this platform=macos so many times"
      "platforms": ["macos"],
    }
  ]
}
```
2025-07-31 21:49:59 +08:00
BiggerRain
b833769c25 refactor: calling service related interfaces (#831)
* chore: server

* chore: add

* refactor: calling service related interfaces

* chore: server list

* chore: add

* chore: add

* update

* chore: remove logs

* focs: update notes

* docs: remove server doc

---------

Co-authored-by: ayang <473033518@qq.com>
2025-07-31 15:59:35 +08:00
ayangweb
855fb2a168 feat: support sending files in chat messages (#764)
* feat: support sending files in chat messages

* update

* update

* update

* update

* update

* update

* update

* update

* update

* update

* update

* update

* update

* update

* update

* update

* update

* update

* update

* update

* update

* update

* update

* update

* update

* update

* update

* update

* update

* docs: update changelog
2025-07-31 15:36:03 +08:00
SteveLauC
d2735ec13b refactor: check Extension/plugin.json from all sources (#846)
Coco App has 4 sources of Extension/plugin.json that should be checked:

1. From the "<data directory>/third_party_extensions" directory
2. Imported via "Import Local Extension"
3. Downloaded from the "store/extension/<extension ID>/_download" API
4. From coco-extensions repository

   Granted, Coco APP won't check these files directly, but we will
   re-use the code and run them in that repository's CI.

Previously, only the Extensions from the first source were checked/validated.
This commit extracts the validation logic to a function and applies it to all
4 sources.

Also, the return value of the Tauri command "list_extensions()" has changed.
We no longer return a boolean indicating if any invalid extensions
are found during loading, which only makes sense when installing
extensions requires users to manually edit data files. Since we now
support extension store and local extension imports, it could be omitted.
2025-07-31 14:27:23 +08:00
SteveLauC
c40fc5818a chore: ignore tauri::AppHandle's generic argument R (#845)
This commit removes the generic argument R from all the AppHandle imports, which
is feasible as it has a default type. This change is made not only for simplicity,
but also **consistency**. Trait SearchSource uses this type:

```rust
pub trait SearchSource {
    async fn search(
        &self,
        tauri_app_handle: AppHandle,
        query: SearchQuery,
    ) -> Result<QueryResponse, SearchError>;
}
```

In order to make trait SearchSource object-safe, the AppHandle used in it cannot
contain generic arguments. So some parts of Coco already omit this generic
argument. This commit cleans up the remaining instances and unifies the usage
project-wide.
2025-07-29 21:55:03 +08:00
SteveLauC
a553ebd593 feat: support Quicklink on Rust side (#760)
This commit implements the support for Quicklink on Rust side. We still
need the frontend part to make this complete.
2025-07-29 16:30:12 +08:00
BiggerRain
232166eb89 chore: delete unused code files and dependencies (#841)
Mainly delete unused webSocket content, and delete other unused code files and dependencies
2025-07-29 13:02:28 +08:00
Medcl
99144950d9 Revert "chore: add macos config for tauri (#837)" (#840)
This reverts commit ee45d21bbe.
2025-07-29 11:04:53 +08:00
SteveLauC
32d4f45144 feat: support installing local extensions (#749)
This commit adds support for installing extensions from a local folder path:

```text
extension-directory/
├── assets/
│   ├── icon.png
│   └── other-assets...
└── plugin.json
```

Useful for testing and development of extensions before publishing.

Co-authored-by: ayang <473033518@qq.com>
2025-07-29 10:26:47 +08:00
BiggerRain
6bc78b41ef chore: web component loading font icon (#838)
* chore: web component loading font icon

* docs: update notes
2025-07-28 19:03:40 +08:00
SteveLauC
cd54beee04 refactor: split query_coco_fusion() (#836)
This commit splits query_coco_fusion() into 2 functions:

1. query_coco_fusion_single_query_source()
2. query_coco_fusion_multi_query_sources()

query_coco_fusion_single_query_source(), as the name suggests, will only search
1 query source. Due to this simplicity, it does not need the complex re-ranking
procedure used by query_coco_fusion_multi_query_sources(), which is the primary
reason why this commit was made.

Another reason behind the change is that the re-ranking logic makes the
search results of querying single query source incorrect, it removes documents
from the results. I didn't investigate the issue because dropping the complex
logic in single query source search would be the best solution here.
2025-07-28 17:29:17 +08:00
ayangweb
ee45d21bbe chore: add macos config for tauri (#837) 2025-07-28 16:35:11 +08:00
159 changed files with 5293 additions and 4160 deletions

2
.env
View File

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

34
.github/workflows/frontend-ci.yml vendored Normal file
View File

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

View File

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

View File

@@ -14,13 +14,32 @@ Information about release notes of Coco App is provided here.
### 🚀 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
### 🐛 Bug fix
- fix: fix issue with update check failure #833
- fix: web component login state #857
### ✈️ 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
## 0.7.1 (2025-07-27)
### ❌ Breaking changes

View File

@@ -31,7 +31,6 @@
"@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",

10
pnpm-lock.yaml generated
View File

@@ -47,9 +47,6 @@ importers:
'@tauri-apps/plugin-updater':
specifier: github:infinilabs/tauri-plugin-updater#v2
version: https://codeload.github.com/infinilabs/tauri-plugin-updater/tar.gz/358e689c65e9943b53eff50bcb9dfd5b1cfc4072
'@tauri-apps/plugin-websocket':
specifier: ~2.3.0
version: 2.3.0
'@tauri-apps/plugin-window':
specifier: 2.0.0-alpha.1
version: 2.0.0-alpha.1
@@ -1261,9 +1258,6 @@ packages:
resolution: {tarball: https://codeload.github.com/infinilabs/tauri-plugin-updater/tar.gz/358e689c65e9943b53eff50bcb9dfd5b1cfc4072}
version: 2.7.1
'@tauri-apps/plugin-websocket@2.3.0':
resolution: {integrity: sha512-eAwRGe3tnqDeQYE0wq4g1PUKbam9tYvlC4uP/au12Y/z7MP4lrS4ylv+aoZ5Ly+hTlBdi7hDkhHomwF/UeBesA==}
'@tauri-apps/plugin-window@2.0.0-alpha.1':
resolution: {integrity: sha512-dFOAgal/3Txz3SQ+LNQq0AK1EPC+acdaFlwPVB/6KXUZYmaFleIlzgxDVoJCQ+/xOhxvYrdQaFLefh0I/Kldbg==}
@@ -4643,10 +4637,6 @@ snapshots:
dependencies:
'@tauri-apps/api': 2.5.0
'@tauri-apps/plugin-websocket@2.3.0':
dependencies:
'@tauri-apps/api': 2.5.0
'@tauri-apps/plugin-window@2.0.0-alpha.1':
dependencies:
'@tauri-apps/api': 2.0.0-alpha.6

188
src-tauri/Cargo.lock generated
View File

@@ -128,7 +128,7 @@ checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487"
[[package]]
name = "applications"
version = "0.3.1"
source = "git+https://github.com/infinilabs/applications-rs?rev=31b0c030a0f3bc82275fe12debe526153978671d#31b0c030a0f3bc82275fe12debe526153978671d"
source = "git+https://github.com/infinilabs/applications-rs?rev=2f1f88d1880404c5f8d70ad950b859bd49922bee#2f1f88d1880404c5f8d70ad950b859bd49922bee"
dependencies = [
"anyhow",
"core-foundation 0.9.4",
@@ -852,7 +852,6 @@ dependencies = [
"cfg-if",
"chinese-number",
"chrono",
"cocoa 0.24.1",
"derive_more 2.0.1",
"dirs 5.0.1",
"enigo",
@@ -862,6 +861,7 @@ dependencies = [
"hostname",
"http 1.3.1",
"hyper 0.14.32",
"indexmap 2.10.0",
"lazy_static",
"log",
"meval",
@@ -878,6 +878,8 @@ dependencies = [
"serde_json",
"serde_plain",
"strsim 0.10.0",
"strum",
"sys-locale",
"sysinfo",
"tauri",
"tauri-build",
@@ -900,13 +902,12 @@ dependencies = [
"tauri-plugin-single-instance",
"tauri-plugin-store",
"tauri-plugin-updater",
"tauri-plugin-websocket",
"tauri-plugin-windows-version",
"thiserror 1.0.69",
"tokio",
"tokio-native-tls",
"tokio-stream",
"tokio-tungstenite 0.20.1",
"tokio-tungstenite",
"tokio-util",
"tungstenite 0.24.0",
"url",
@@ -915,22 +916,6 @@ dependencies = [
"zip 4.0.0",
]
[[package]]
name = "cocoa"
version = "0.24.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f425db7937052c684daec3bd6375c8abe2d146dca4b8b143d6db777c39138f3a"
dependencies = [
"bitflags 1.3.2",
"block",
"cocoa-foundation 0.1.2",
"core-foundation 0.9.4",
"core-graphics 0.22.3",
"foreign-types 0.3.2",
"libc",
"objc",
]
[[package]]
name = "cocoa"
version = "0.26.0"
@@ -939,28 +924,14 @@ checksum = "f79398230a6e2c08f5c9760610eb6924b52aa9e7950a619602baba59dcbbdbb2"
dependencies = [
"bitflags 2.9.0",
"block",
"cocoa-foundation 0.2.0",
"cocoa-foundation",
"core-foundation 0.10.0",
"core-graphics 0.24.0",
"core-graphics",
"foreign-types 0.5.0",
"libc",
"objc",
]
[[package]]
name = "cocoa-foundation"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c6234cbb2e4c785b456c0644748b1ac416dd045799740356f8363dfe00c93f7"
dependencies = [
"bitflags 1.3.2",
"block",
"core-foundation 0.9.4",
"core-graphics-types 0.1.3",
"libc",
"objc",
]
[[package]]
name = "cocoa-foundation"
version = "0.2.0"
@@ -970,7 +941,7 @@ dependencies = [
"bitflags 2.9.0",
"block",
"core-foundation 0.10.0",
"core-graphics-types 0.2.0",
"core-graphics-types",
"libc",
"objc",
]
@@ -1087,19 +1058,6 @@ version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "core-graphics"
version = "0.22.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2581bbab3b8ffc6fcbd550bf46c355135d16e9ff2a6ea032ad6b9bf1d7efe4fb"
dependencies = [
"bitflags 1.3.2",
"core-foundation 0.9.4",
"core-graphics-types 0.1.3",
"foreign-types 0.3.2",
"libc",
]
[[package]]
name = "core-graphics"
version = "0.24.0"
@@ -1108,22 +1066,11 @@ checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1"
dependencies = [
"bitflags 2.9.0",
"core-foundation 0.10.0",
"core-graphics-types 0.2.0",
"core-graphics-types",
"foreign-types 0.5.0",
"libc",
]
[[package]]
name = "core-graphics-types"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf"
dependencies = [
"bitflags 1.3.2",
"core-foundation 0.9.4",
"libc",
]
[[package]]
name = "core-graphics-types"
version = "0.2.0"
@@ -1527,8 +1474,8 @@ version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67fd9ae1736d6ebb2e472740fbee86fb2178b8d56feb98a6751411d4c95b7e72"
dependencies = [
"cocoa 0.26.0",
"core-graphics 0.24.0",
"cocoa",
"core-graphics",
"dunce",
"gdk",
"gdkx11",
@@ -1617,7 +1564,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cf6f550bbbdd5fe66f39d429cb2604bcdacbf00dca0f5bbe2e9306a0009b7c6"
dependencies = [
"core-foundation 0.10.0",
"core-graphics 0.24.0",
"core-graphics",
"foreign-types-shared 0.3.1",
"libc",
"log",
@@ -2487,7 +2434,7 @@ dependencies = [
"futures-core",
"futures-sink",
"http 1.3.1",
"indexmap 2.9.0",
"indexmap 2.10.0",
"slab",
"tokio",
"tokio-util",
@@ -2956,9 +2903,9 @@ dependencies = [
[[package]]
name = "indexmap"
version = "2.9.0"
version = "2.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e"
checksum = "fe4cd85333e22411419a0bcae1297d25e58c9443848b11dc6a86fefe8c78a661"
dependencies = [
"equivalent",
"hashbrown 0.15.3",
@@ -3255,9 +3202,9 @@ dependencies = [
[[package]]
name = "libc"
version = "0.2.172"
version = "0.2.175"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa"
checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543"
[[package]]
name = "libdbus-sys"
@@ -4589,7 +4536,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eac26e981c03a6e53e0aee43c113e3202f5581d5360dae7bd2c70e800dd0451d"
dependencies = [
"base64 0.22.1",
"indexmap 2.9.0",
"indexmap 2.10.0",
"quick-xml 0.32.0",
"serde",
"time",
@@ -5553,7 +5500,7 @@ version = "1.0.140"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373"
dependencies = [
"indexmap 2.9.0",
"indexmap 2.10.0",
"itoa 1.0.15",
"memchr",
"ryu",
@@ -5611,7 +5558,7 @@ dependencies = [
"chrono",
"hex",
"indexmap 1.9.3",
"indexmap 2.9.0",
"indexmap 2.10.0",
"serde",
"serde_derive",
"serde_json",
@@ -5776,7 +5723,7 @@ checksum = "18051cdd562e792cad055119e0cdb2cfc137e44e3987532e0f9659a77931bb08"
dependencies = [
"bytemuck",
"cfg_aliases",
"core-graphics 0.24.0",
"core-graphics",
"foreign-types 0.5.0",
"js-sys",
"log",
@@ -5865,6 +5812,27 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "strum"
version = "0.27.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf"
dependencies = [
"strum_macros",
]
[[package]]
name = "strum_macros"
version = "0.27.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7"
dependencies = [
"heck 0.5.0",
"proc-macro2",
"quote",
"syn 2.0.101",
]
[[package]]
name = "subtle"
version = "2.6.1"
@@ -6002,7 +5970,7 @@ checksum = "1e59c1f38e657351a2e822eadf40d6a2ad4627b9c25557bc1180ec1b3295ef82"
dependencies = [
"bitflags 2.9.0",
"core-foundation 0.10.0",
"core-graphics 0.24.0",
"core-graphics",
"crossbeam-channel",
"dispatch",
"dlopen2",
@@ -6200,9 +6168,9 @@ source = "git+https://github.com/ahkohd/tauri-nspanel?branch=v2#d4b9df797959f8fa
dependencies = [
"bitflags 2.9.0",
"block",
"cocoa 0.26.0",
"cocoa",
"core-foundation 0.10.0",
"core-graphics 0.24.0",
"core-graphics",
"objc",
"objc-foundation",
"objc_id",
@@ -6556,25 +6524,6 @@ dependencies = [
"zip 2.6.1",
]
[[package]]
name = "tauri-plugin-websocket"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af3ac71aec5fb0ae5441e830cd075b1cbed49ac3d39cb975a4894ea8fa2e62b9"
dependencies = [
"futures-util",
"http 1.3.1",
"log",
"rand 0.8.5",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"thiserror 2.0.12",
"tokio",
"tokio-tungstenite 0.26.2",
]
[[package]]
name = "tauri-plugin-windows-version"
version = "2.0.0"
@@ -6682,7 +6631,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8d321dbc6f998d825ab3f0d62673e810c861aac2d0de2cc2c395328f1d113b4"
dependencies = [
"embed-resource",
"indexmap 2.9.0",
"indexmap 2.10.0",
"toml",
]
@@ -6909,22 +6858,6 @@ dependencies = [
"tungstenite 0.20.1",
]
[[package]]
name = "tokio-tungstenite"
version = "0.26.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084"
dependencies = [
"futures-util",
"log",
"rustls",
"rustls-pki-types",
"tokio",
"tokio-rustls",
"tungstenite 0.26.2",
"webpki-roots 0.26.11",
]
[[package]]
name = "tokio-util"
version = "0.7.15"
@@ -6965,7 +6898,7 @@ version = "0.19.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421"
dependencies = [
"indexmap 2.9.0",
"indexmap 2.10.0",
"toml_datetime",
"winnow 0.5.40",
]
@@ -6976,7 +6909,7 @@ version = "0.20.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70f427fce4d84c72b5b732388bf4a9f4531b53f74e2887e3ecb2481f68f66d81"
dependencies = [
"indexmap 2.9.0",
"indexmap 2.10.0",
"toml_datetime",
"winnow 0.5.40",
]
@@ -6987,7 +6920,7 @@ version = "0.22.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "310068873db2c5b3e7659d2cc35d21855dbafa50d1ce336397c666e3cb08137e"
dependencies = [
"indexmap 2.9.0",
"indexmap 2.10.0",
"serde",
"serde_spanned",
"toml_datetime",
@@ -7131,25 +7064,6 @@ dependencies = [
"utf-8",
]
[[package]]
name = "tungstenite"
version = "0.26.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13"
dependencies = [
"bytes",
"data-encoding",
"http 1.3.1",
"httparse",
"log",
"rand 0.9.1",
"rustls",
"rustls-pki-types",
"sha1",
"thiserror 2.0.12",
"utf-8",
]
[[package]]
name = "typeid"
version = "1.0.3"
@@ -8620,7 +8534,7 @@ dependencies = [
"arbitrary",
"crc32fast",
"crossbeam-utils",
"indexmap 2.9.0",
"indexmap 2.10.0",
"memchr",
]
@@ -8639,7 +8553,7 @@ dependencies = [
"flate2",
"getrandom 0.3.2",
"hmac",
"indexmap 2.9.0",
"indexmap 2.10.0",
"liblzma",
"memchr",
"pbkdf2",

View File

@@ -51,7 +51,6 @@ serde = { version = "1", features = ["derive"] }
# see: https://docs.rs/serde_json/latest/serde_json/struct.Number.html#method.from_u128
serde_json = { version = "1", features = ["arbitrary_precision", "preserve_order"] }
tauri-plugin-http = "2"
tauri-plugin-websocket = "2"
tauri-plugin-deep-link = "2.0.0"
tauri-plugin-store = "2.2.0"
tauri-plugin-os = "2"
@@ -62,7 +61,7 @@ tauri-plugin-drag = "2"
tauri-plugin-macos-permissions = "2"
tauri-plugin-fs-pro = "2"
tauri-plugin-screenshots = "2"
applications = { git = "https://github.com/infinilabs/applications-rs", rev = "31b0c030a0f3bc82275fe12debe526153978671d" }
applications = { git = "https://github.com/infinilabs/applications-rs", rev = "2f1f88d1880404c5f8d70ad950b859bd49922bee" }
tokio-native-tls = "0.3" # For wss connections
tokio = { version = "1", features = ["full"] }
tokio-tungstenite = { version = "0.20", features = ["native-tls"] }
@@ -106,10 +105,12 @@ camino = "1.1.10"
tokio-stream = { version = "0.1.17", features = ["io-util"] }
cfg-if = "1.0.1"
sysinfo = "0.35.2"
indexmap = { version = "2.10.0", features = ["serde"] }
strum = { version = "0.27.2", features = ["derive"] }
sys-locale = "0.3.2"
[target."cfg(target_os = \"macos\")".dependencies]
tauri-nspanel = { git = "https://github.com/ahkohd/tauri-nspanel", branch = "v2" }
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"] }

View File

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

View File

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

View File

@@ -1,15 +1,15 @@
use std::{fs::create_dir, io::Read};
use tauri::{Manager, Runtime};
use tauri::{AppHandle, Manager};
use tauri_plugin_autostart::ManagerExt;
/// If the state reported from the OS and the state stored by us differ, our state is
/// prioritized and seen as the correct one. Update the OS state to make them consistent.
pub fn ensure_autostart_state_consistent(app: &mut tauri::App) -> Result<(), String> {
let autostart_manager = app.autolaunch();
pub fn ensure_autostart_state_consistent(tauri_app_handle: &AppHandle) -> Result<(), String> {
let autostart_manager = tauri_app_handle.autolaunch();
let os_state = autostart_manager.is_enabled().map_err(|e| e.to_string())?;
let coco_stored_state = current_autostart(app.app_handle()).map_err(|e| e.to_string())?;
let coco_stored_state = current_autostart(tauri_app_handle).map_err(|e| e.to_string())?;
if os_state != coco_stored_state {
log::warn!(
@@ -42,7 +42,7 @@ pub fn ensure_autostart_state_consistent(app: &mut tauri::App) -> Result<(), Str
Ok(())
}
fn current_autostart<R: Runtime>(app: &tauri::AppHandle<R>) -> Result<bool, String> {
fn current_autostart(app: &tauri::AppHandle) -> Result<bool, String> {
use std::fs::File;
let path = app.path().app_config_dir().unwrap();
@@ -65,10 +65,7 @@ fn current_autostart<R: Runtime>(app: &tauri::AppHandle<R>) -> Result<bool, Stri
}
#[tauri::command]
pub async fn change_autostart<R: Runtime>(
app: tauri::AppHandle<R>,
open: bool,
) -> Result<(), String> {
pub async fn change_autostart(app: tauri::AppHandle, open: bool) -> Result<(), String> {
use std::fs::File;
use std::io::Write;

View File

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

View File

@@ -1,7 +1,7 @@
use crate::extension::ExtensionSettings;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use tauri::AppHandle;
use tauri::Runtime;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RichLabel {
@@ -31,17 +31,49 @@ pub struct EditorInfo {
pub timestamp: Option<String>,
}
/// Defines the action that would be performed when a document gets opened.
/// Defines the action that would be performed when a [document](Document) gets opened.
///
/// "Document" is a uniform type that the backend uses to send the search results
/// back to the frontend. Since Coco can search many sources, "Document" can
/// represent different things, application, web page, local file, extensions, and
/// so on. Each has its own specific open action.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) enum OnOpened {
/// Launch the application
Application { app_path: String },
/// Open the URL.
Document { url: String },
/// 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>,
}
#[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>,
},
}
impl OnOpened {
@@ -49,62 +81,121 @@ impl OnOpened {
match self {
Self::Application { app_path } => app_path.clone(),
Self::Document { url } => url.clone(),
Self::Command { action } => {
const WHITESPACE: &str = " ";
let mut ret = action.exec.clone();
ret.push_str(WHITESPACE);
if let Some(ref args) = action.args {
ret.push_str(args.join(WHITESPACE).as_str());
}
Self::Extension(ext_on_opened) => {
match &ext_on_opened.ty {
ExtensionOnOpenedType::Command { action } => {
const WHITESPACE: &str = " ";
let mut ret = action.exec.clone();
ret.push_str(WHITESPACE);
if let Some(ref args) = action.args {
ret.push_str(args.join(WHITESPACE).as_str());
}
ret
ret
}
// Currently, our URL is static and does not support dynamic parameters.
// The URL of a quicklink is nearly useless without such dynamic user
// inputs, so until we have dynamic URL support, we just use "N/A".
ExtensionOnOpenedType::Quicklink { .. } => String::from("N/A"),
}
}
}
}
}
#[tauri::command]
pub(crate) async fn open<R: Runtime>(
tauri_app_handle: AppHandle<R>,
pub(crate) async fn open(
tauri_app_handle: AppHandle,
on_opened: OnOpened,
extra_args: Option<HashMap<String, String>>,
) -> Result<(), String> {
log::debug!("open({})", on_opened.url());
use crate::util::open as homemade_tauri_shell_open;
use std::process::Command;
match on_opened {
OnOpened::Application { app_path } => {
log::debug!("open application [{}]", app_path);
homemade_tauri_shell_open(tauri_app_handle.clone(), app_path).await?
}
OnOpened::Document { url } => {
log::debug!("open document [{}]", url);
homemade_tauri_shell_open(tauri_app_handle.clone(), url).await?
}
OnOpened::Command { action } => {
let mut cmd = Command::new(action.exec);
if let Some(args) = action.args {
cmd.args(args);
OnOpened::Extension(ext_on_opened) => {
// Apply the settings that would affect open behavior
if let Some(settings) = ext_on_opened.settings {
if let Some(should_hide) = settings.hide_before_open {
if should_hide {
crate::hide_coco(tauri_app_handle.clone()).await;
}
}
}
let 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)
));
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?
}
}
}
}
}
}

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, Runtime, async_runtime};
use tauri_plugin_fs_pro::{IconOptions, icon, metadata, name};
use tauri::{AppHandle, Manager, async_runtime};
use tauri_plugin_fs_pro::{IconOptions, icon, metadata};
use tauri_plugin_global_shortcut::GlobalShortcutExt;
use tauri_plugin_global_shortcut::Shortcut;
use tauri_plugin_global_shortcut::ShortcutEvent;
@@ -36,7 +36,13 @@ use tauri_plugin_global_shortcut::ShortcutState;
use tauri_plugin_store::StoreExt;
use tokio::sync::oneshot::Sender as OneshotSender;
// Deprecated. We no longer index this field, but to be backward-compatible, we
// have to keep it.
const FIELD_APP_NAME: &str = "app_name";
const FIELD_APP_NAME_IN_SYSTEM_LANG: &str = "app_name_in_system_lang";
const FIELD_APP_NAME_ZH: &str = "app_name_zh";
const FIELD_APP_NAME_EN: &str = "app_name_en";
const FIELD_ICON_PATH: &str = "icon_path";
const FIELD_APP_ALIAS: &str = "app_alias";
const APPLICATION_SEARCH_SOURCE_ID: &str = "application";
@@ -58,37 +64,18 @@ const INDEX_DIR: &str = "local_application_index";
pub(crate) const QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME: &str = "Applications";
pub fn get_default_search_paths() -> Vec<String> {
#[cfg(target_os = "macos")]
{
let home_dir =
PathBuf::from(std::env::var_os("HOME").expect("environment variable $HOME not found"));
return vec![
"/Applications".into(),
"/System/Applications".into(),
"/System/Library/CoreServices".into(),
home_dir
.join("Applications")
.into_os_string()
.into_string()
.expect("this path should be UTF-8 encoded"),
];
let paths = applications::get_default_search_paths();
let mut ret = Vec::with_capacity(paths.len());
for search_path in paths {
let path_string = search_path
.into_os_string()
.into_string()
.expect("path should be UTF-8 encoded");
ret.push(path_string);
}
#[cfg(not(target_os = "macos"))]
{
let paths = applications::get_default_search_paths();
let mut ret = Vec::with_capacity(paths.len());
for search_path in paths {
let path_string = search_path
.into_os_string()
.into_string()
.expect("path should be UTF-8 encoded");
ret.push(path_string);
}
ret
}
ret
}
/// Helper function to return `app`'s path.
@@ -115,26 +102,63 @@ fn get_app_path(app: &App) -> String {
.expect("should be UTF-8 encoded")
}
/// Helper function to return `app`'s path.
///
/// * macOS: extract `app_path`'s file name and remove the file extension
/// * Windows/Linux: return the name specified in `.desktop` file
async fn get_app_name(app: &App) -> String {
if cfg!(any(target_os = "linux", target_os = "windows")) {
app.name.clone()
/// Helper function to return `app`'s Chinese name.
async fn get_app_name_zh(app: &App) -> String {
// zh_CN or zh-CN
if let Some(name) = app.localized_app_names.get("zh_CN") {
return name.clone();
}
if let Some(name) = app.localized_app_names.get("zh-CN") {
return name.clone();
}
// zh_Hans or zh-Hans
if let Some(name) = app.localized_app_names.get("zh_Hans") {
return name.clone();
}
if let Some(name) = app.localized_app_names.get("zh-Hans") {
return name.clone();
}
// Fall back to base name
app.name.clone()
}
/// Helper function to return `app`'s English name.
async fn get_app_name_en(app: &App) -> String {
// en_US or en-US
if let Some(name) = app.localized_app_names.get("en_US") {
return name.clone();
}
if let Some(name) = app.localized_app_names.get("en-US") {
return name.clone();
}
// English (General)
if let Some(name) = app.localized_app_names.get("en") {
return name.clone();
}
// Fall back to base name
app.name.clone()
}
/// Helper function to return `app`'s name in system language.
async fn get_app_name_in_system_lang(app: &App) -> String {
let system_lang = crate::util::system_lang::get_system_lang();
if let Some(name) = app.localized_app_names.get(&system_lang) {
name.clone()
} else {
let app_path = get_app_path(app);
name(app_path.into()).await
// Fall back to base name
app.name.clone()
}
}
/// Helper function to return an absolute path to `app`'s icon.
///
/// On macOS/Windows, we cache icons in our data directory using the `icon()` function.
async fn get_app_icon_path<R: Runtime>(
tauri_app_handle: &AppHandle<R>,
app: &App,
) -> Result<String, String> {
async fn get_app_icon_path(tauri_app_handle: &AppHandle, app: &App) -> Result<String, String> {
let res_path = if cfg!(target_os = "linux") {
let icon_path = app
.icon_path
@@ -213,8 +237,8 @@ impl SearchSourceState for ApplicationSearchSourceState {
}
/// Index applications if they have not been indexed (by checking if `app_index_dir` exists).
async fn index_applications_if_not_indexed<R: Runtime>(
tauri_app_handle: &AppHandle<R>,
async fn index_applications_if_not_indexed(
tauri_app_handle: &AppHandle,
app_index_dir: &Path,
) -> anyhow::Result<ApplicationSearchSourceState> {
let index_exists = app_index_dir.exists();
@@ -224,9 +248,17 @@ async fn index_applications_if_not_indexed<R: Runtime>(
pizza_engine_builder.set_data_store(disk_store);
let mut schema = Schema::new();
let field_app_name = Property::builder(FieldType::Text).build();
let field_app_name_zh = Property::builder(FieldType::Text).build();
schema
.add_property(FIELD_APP_NAME, field_app_name)
.add_property(FIELD_APP_NAME_ZH, field_app_name_zh)
.expect("no collision could happen");
let field_app_name_en = Property::builder(FieldType::Text).build();
schema
.add_property(FIELD_APP_NAME_EN, field_app_name_en)
.expect("no collision could happen");
let field_app_name_in_system_lang = Property::builder(FieldType::Text).build();
schema
.add_property(FIELD_APP_NAME_IN_SYSTEM_LANG, field_app_name_in_system_lang)
.expect("no collision could happen");
let property_icon = Property::builder(FieldType::Text).index(false).build();
schema
@@ -271,21 +303,39 @@ async fn index_applications_if_not_indexed<R: Runtime>(
for app in apps.iter() {
let app_path = get_app_path(app);
let app_name = get_app_name(app).await;
let app_name_zh = get_app_name_zh(app).await;
let app_name_en = get_app_name_en(app).await;
let app_name_in_system_lang = get_app_name_in_system_lang(app).await;
let app_icon_path = get_app_icon_path(&tauri_app_handle, app)
.await
.map_err(|str| anyhow::anyhow!(str))?;
let app_alias = get_app_alias(&tauri_app_handle, &app_path).unwrap_or(String::new());
if app_name.is_empty() || app_name.eq(&tauri_app_handle.package_info().name) {
// Skip if all names are empty
if app_name_zh.is_empty()
&& app_name_en.is_empty()
&& app_name_in_system_lang.is_empty()
{
continue;
}
// Skip if this is Coco itself
//
// Coco does not have localized app names, so app_name_en and app_name_zh
// should both have value "Coco-AI", so either should work.
if app_name_en == tauri_app_handle.package_info().name {
continue;
}
// You cannot write `app_name.clone()` within the `doc!()` macro, we should fix this.
let app_name_clone = app_name.clone();
let app_name_zh_clone = app_name_zh.clone();
let app_name_en_clone = app_name_en.clone();
let app_name_in_system_lang = app_name_in_system_lang.clone();
let app_path_clone = app_path.clone();
let document = doc!( app_path_clone, {
FIELD_APP_NAME => app_name_clone,
FIELD_APP_NAME_ZH => app_name_zh_clone,
FIELD_APP_NAME_EN => app_name_en_clone,
FIELD_APP_NAME_IN_SYSTEM_LANG => app_name_in_system_lang,
FIELD_ICON_PATH => app_icon_path,
FIELD_APP_ALIAS => app_alias,
}
@@ -294,8 +344,8 @@ async fn index_applications_if_not_indexed<R: Runtime>(
// We don't error out because one failure won't break the whole thing
if let Err(e) = writer.create_document(document).await {
warn!(
"failed to index application [app name: '{}', app path: '{}'] due to error [{}]",
app_name, app_path, e
"failed to index application [app name zh: '{}', app name en: '{}', app path: '{}'] due to error [{}]",
app_name_zh, app_name_en, app_path, e
)
}
}
@@ -315,13 +365,13 @@ async fn index_applications_if_not_indexed<R: Runtime>(
}
/// Upon application start, index all the applications found in the `get_default_search_paths()`.
struct IndexAllApplicationsTask<R: Runtime> {
tauri_app_handle: AppHandle<R>,
struct IndexAllApplicationsTask {
tauri_app_handle: AppHandle,
callback: Option<tokio::sync::oneshot::Sender<Result<(), String>>>,
}
#[async_trait::async_trait(?Send)]
impl<R: Runtime> Task for IndexAllApplicationsTask<R> {
impl Task for IndexAllApplicationsTask {
fn search_source_id(&self) -> &'static str {
APPLICATION_SEARCH_SOURCE_ID
}
@@ -343,13 +393,13 @@ impl<R: Runtime> Task for IndexAllApplicationsTask<R> {
}
}
struct ReindexAllApplicationsTask<R: Runtime> {
tauri_app_handle: AppHandle<R>,
struct ReindexAllApplicationsTask {
tauri_app_handle: AppHandle,
callback: Option<tokio::sync::oneshot::Sender<Result<(), String>>>,
}
#[async_trait::async_trait(?Send)]
impl<R: Runtime> Task for ReindexAllApplicationsTask<R> {
impl Task for ReindexAllApplicationsTask {
fn search_source_id(&self) -> &'static str {
APPLICATION_SEARCH_SOURCE_ID
}
@@ -377,14 +427,14 @@ impl<R: Runtime> Task for ReindexAllApplicationsTask<R> {
}
}
struct SearchApplicationsTask<R: Runtime> {
tauri_app_handle: AppHandle<R>,
struct SearchApplicationsTask {
tauri_app_handle: AppHandle,
query_string: String,
callback: Option<OneshotSender<Result<SearchResult, PizzaEngineError>>>,
}
#[async_trait::async_trait(?Send)]
impl<R: Runtime> Task for SearchApplicationsTask<R> {
impl Task for SearchApplicationsTask {
fn search_source_id(&self) -> &'static str {
APPLICATION_SEARCH_SOURCE_ID
}
@@ -424,9 +474,19 @@ impl<R: Runtime> Task for SearchApplicationsTask<R> {
//
// It will be passed to Pizza like "Google\nChrome". Using Display impl would result
// in an invalid query DSL and serde will complain.
//
// In order to be backward compatible, we still do match and prefix queries to the
// app_name field.
let dsl = format!(
"{{ \"query\": {{ \"bool\": {{ \"should\": [ {{ \"match\": {{ \"{FIELD_APP_NAME}\": {:?} }} }}, {{ \"prefix\": {{ \"{FIELD_APP_NAME}\": {:?} }} }} ] }} }} }}",
self.query_string, self.query_string
"{{ \"query\": {{ \"bool\": {{ \"should\": [ {{ \"match\": {{ \"{FIELD_APP_NAME_ZH}\": {:?} }} }}, {{ \"prefix\": {{ \"{FIELD_APP_NAME_ZH}\": {:?} }} }}, {{ \"match\": {{ \"{FIELD_APP_NAME_EN}\": {:?} }} }}, {{ \"prefix\": {{ \"{FIELD_APP_NAME_EN}\": {:?} }} }}, {{ \"match\": {{ \"{FIELD_APP_NAME_IN_SYSTEM_LANG}\": {:?} }} }}, {{ \"prefix\": {{ \"{FIELD_APP_NAME_IN_SYSTEM_LANG}\": {:?} }} }}, {{ \"match\": {{ \"{FIELD_APP_NAME}\": {:?} }} }}, {{ \"prefix\": {{ \"{FIELD_APP_NAME}\": {:?} }} }} ] }} }} }}",
self.query_string,
self.query_string,
self.query_string,
self.query_string,
self.query_string,
self.query_string,
self.query_string,
self.query_string
);
let state = state
@@ -514,9 +574,7 @@ impl Task for IndexNewApplicationsTask {
pub struct ApplicationSearchSource;
impl ApplicationSearchSource {
pub async fn prepare_index_and_store<R: Runtime>(
app_handle: AppHandle<R>,
) -> Result<(), String> {
pub async fn prepare_index_and_store(app_handle: AppHandle) -> Result<(), String> {
app_handle
.store(TAURI_STORE_APP_HOTKEY)
.map_err(|e| e.to_string())?;
@@ -625,7 +683,7 @@ impl SearchSource for ApplicationSearchSource {
let total_hits = search_result.total_hits;
let source = self.get_type();
let hits = pizza_engine_hits_to_coco_hits(search_result.hits);
let hits = pizza_engine_hits_to_coco_hits(search_result.hits).await;
Ok(QueryResponse {
source,
@@ -635,9 +693,11 @@ impl SearchSource for ApplicationSearchSource {
}
}
fn pizza_engine_hits_to_coco_hits(
async fn pizza_engine_hits_to_coco_hits(
pizza_engine_hits: Option<Vec<PizzaEngineDocument>>,
) -> Vec<(Document, f64)> {
use crate::util::app_lang::{Lang, get_app_lang};
let Some(engine_hits) = pizza_engine_hits else {
return Vec::new();
};
@@ -646,10 +706,43 @@ fn pizza_engine_hits_to_coco_hits(
for engine_hit in engine_hits {
let score = engine_hit.score.unwrap_or(0.0) as f64;
let mut document_fields = engine_hit.fields;
let app_name = match document_fields.remove(FIELD_APP_NAME).unwrap() {
FieldValue::Text(string) => string,
_ => unreachable!("field name is of type Text"),
// Get both Chinese and English names
let opt_app_name_zh = match document_fields.remove(FIELD_APP_NAME_ZH) {
Some(FieldValue::Text(string)) => Some(string),
_ => None,
};
let opt_app_name_en = match document_fields.remove(FIELD_APP_NAME_EN) {
Some(FieldValue::Text(string)) => Some(string),
_ => None,
};
let opt_app_name_deprecated = match document_fields.remove(FIELD_APP_NAME) {
Some(FieldValue::Text(string)) => Some(string),
_ => None,
};
let app_name: String = {
if let Some(legacy_app_name) = opt_app_name_deprecated {
// Old version of index, which only contains the field app_name.
legacy_app_name
} else {
// New version of index store the following 2 fields
let panic_msg = format!(
"new version of index should contain field [{}] and [{}]",
FIELD_APP_NAME_EN, FIELD_APP_NAME_ZH
);
let app_name_zh = opt_app_name_zh.expect(&panic_msg);
let app_name_en = opt_app_name_en.expect(&panic_msg);
// Choose the appropriate name based on current language
match get_app_lang().await {
Lang::zh_CN => app_name_zh,
Lang::en_US => app_name_en,
}
}
};
let app_path = engine_hit.key.expect("key should be set to app path");
let app_icon_path = match document_fields.remove(FIELD_ICON_PATH).unwrap() {
FieldValue::Text(string) => string,
@@ -669,7 +762,7 @@ fn pizza_engine_hits_to_coco_hits(
}),
id: app_path.clone(),
category: Some("Application".to_string()),
title: Some(app_name.clone()),
title: Some(app_name),
icon: Some(app_icon_path),
on_opened: Some(on_opened),
url: Some(url),
@@ -683,7 +776,7 @@ fn pizza_engine_hits_to_coco_hits(
coco_hits
}
pub fn set_app_alias<R: Runtime>(tauri_app_handle: &AppHandle<R>, app_path: &str, alias: &str) {
pub fn set_app_alias(tauri_app_handle: &AppHandle, app_path: &str, alias: &str) {
let store = tauri_app_handle
.store(TAURI_STORE_APP_ALIAS)
.unwrap_or_else(|_| panic!("store [{}] not found/loaded", TAURI_STORE_APP_ALIAS));
@@ -696,7 +789,7 @@ pub fn set_app_alias<R: Runtime>(tauri_app_handle: &AppHandle<R>, app_path: &str
// deleted while updating it.
}
fn get_app_alias<R: Runtime>(tauri_app_handle: &AppHandle<R>, app_path: &str) -> Option<String> {
fn get_app_alias(tauri_app_handle: &AppHandle, app_path: &str) -> Option<String> {
let store = tauri_app_handle
.store(TAURI_STORE_APP_ALIAS)
.unwrap_or_else(|_| panic!("store [{}] not found/loaded", TAURI_STORE_APP_ALIAS));
@@ -714,9 +807,9 @@ fn get_app_alias<R: Runtime>(tauri_app_handle: &AppHandle<R>, app_path: &str) ->
/// The handler that will be invoked when an application hotkey is pressed.
///
/// The `app_path` argument is for logging-only.
fn app_hotkey_handler<R: Runtime>(
fn app_hotkey_handler(
app_path: String,
) -> impl Fn(&AppHandle<R>, &Shortcut, ShortcutEvent) + Send + Sync + 'static {
) -> impl Fn(&AppHandle, &Shortcut, ShortcutEvent) + Send + Sync + 'static {
move |tauri_app_handle, _hot_key, event| {
if event.state() == ShortcutState::Pressed {
let app_path_clone = app_path.clone();
@@ -732,7 +825,7 @@ fn app_hotkey_handler<R: Runtime>(
}
/// For all the applications, if it is enabled & has hotkey set, then set it up.
pub(crate) fn set_apps_hotkey<R: Runtime>(tauri_app_handle: &AppHandle<R>) -> Result<(), String> {
pub(crate) fn set_apps_hotkey(tauri_app_handle: &AppHandle) -> Result<(), String> {
let app_hotkey_store = tauri_app_handle
.store(TAURI_STORE_APP_HOTKEY)
.unwrap_or_else(|_| panic!("store [{}] not found/loaded", TAURI_STORE_APP_HOTKEY));
@@ -756,7 +849,7 @@ pub(crate) fn set_apps_hotkey<R: Runtime>(tauri_app_handle: &AppHandle<R>) -> Re
}
/// For all the applications, if it is enabled & has hotkey set, then unset it.
pub(crate) fn unset_apps_hotkey<R: Runtime>(tauri_app_handle: &AppHandle<R>) -> Result<(), String> {
pub(crate) fn unset_apps_hotkey(tauri_app_handle: &AppHandle) -> Result<(), String> {
let app_hotkey_store = tauri_app_handle
.store(TAURI_STORE_APP_HOTKEY)
.unwrap_or_else(|_| panic!("store [{}] not found/loaded", TAURI_STORE_APP_HOTKEY));
@@ -783,8 +876,8 @@ pub(crate) fn unset_apps_hotkey<R: Runtime>(tauri_app_handle: &AppHandle<R>) ->
}
/// Set the hotkey but won't persist this settings change.
pub(crate) fn set_app_hotkey<R: Runtime>(
tauri_app_handle: &AppHandle<R>,
pub(crate) fn set_app_hotkey(
tauri_app_handle: &AppHandle,
app_path: &str,
hotkey: &str,
) -> Result<(), String> {
@@ -794,8 +887,8 @@ pub(crate) fn set_app_hotkey<R: Runtime>(
.map_err(|e| e.to_string())
}
pub fn register_app_hotkey<R: Runtime>(
tauri_app_handle: &AppHandle<R>,
pub fn register_app_hotkey(
tauri_app_handle: &AppHandle,
app_path: &str,
hotkey: &str,
) -> Result<(), String> {
@@ -812,10 +905,7 @@ pub fn register_app_hotkey<R: Runtime>(
Ok(())
}
pub fn unregister_app_hotkey<R: Runtime>(
tauri_app_handle: &AppHandle<R>,
app_path: &str,
) -> Result<(), String> {
pub fn unregister_app_hotkey(tauri_app_handle: &AppHandle, app_path: &str) -> Result<(), String> {
let app_hotkey_store = tauri_app_handle
.store(TAURI_STORE_APP_HOTKEY)
.unwrap_or_else(|_| panic!("store [{}] not found/loaded", TAURI_STORE_APP_HOTKEY));
@@ -855,7 +945,7 @@ pub fn unregister_app_hotkey<R: Runtime>(
Ok(())
}
fn get_disabled_app_list<R: Runtime>(tauri_app_handle: &AppHandle<R>) -> Vec<String> {
fn get_disabled_app_list(tauri_app_handle: &AppHandle) -> Vec<String> {
let store = tauri_app_handle
.store(TAURI_STORE_DISABLED_APP_LIST_AND_SEARCH_PATH)
.unwrap_or_else(|_| {
@@ -892,10 +982,7 @@ pub fn is_app_search_enabled(app_path: &str) -> bool {
disabled_app_list.iter().all(|path| path != app_path)
}
pub fn disable_app_search<R: Runtime>(
tauri_app_handle: &AppHandle<R>,
app_path: &str,
) -> Result<(), String> {
pub fn disable_app_search(tauri_app_handle: &AppHandle, app_path: &str) -> Result<(), String> {
let store = tauri_app_handle
.store(TAURI_STORE_DISABLED_APP_LIST_AND_SEARCH_PATH)
.unwrap_or_else(|_| {
@@ -939,10 +1026,7 @@ pub fn disable_app_search<R: Runtime>(
Ok(())
}
pub fn enable_app_search<R: Runtime>(
tauri_app_handle: &AppHandle<R>,
app_path: &str,
) -> Result<(), String> {
pub fn enable_app_search(tauri_app_handle: &AppHandle, app_path: &str) -> Result<(), String> {
let store = tauri_app_handle
.store(TAURI_STORE_DISABLED_APP_LIST_AND_SEARCH_PATH)
.unwrap_or_else(|_| {
@@ -984,8 +1068,8 @@ pub fn enable_app_search<R: Runtime>(
}
#[tauri::command]
pub async fn add_app_search_path<R: Runtime>(
tauri_app_handle: AppHandle<R>,
pub async fn add_app_search_path(
tauri_app_handle: AppHandle,
search_path: String,
) -> Result<(), String> {
let mut search_paths = get_app_search_path(tauri_app_handle.clone()).await;
@@ -1010,8 +1094,8 @@ pub async fn add_app_search_path<R: Runtime>(
}
#[tauri::command]
pub async fn remove_app_search_path<R: Runtime>(
tauri_app_handle: AppHandle<R>,
pub async fn remove_app_search_path(
tauri_app_handle: AppHandle,
search_path: String,
) -> Result<(), String> {
let mut search_paths = get_app_search_path(tauri_app_handle.clone()).await;
@@ -1036,7 +1120,7 @@ pub async fn remove_app_search_path<R: Runtime>(
}
#[tauri::command]
pub async fn get_app_search_path<R: Runtime>(tauri_app_handle: AppHandle<R>) -> Vec<String> {
pub async fn get_app_search_path(tauri_app_handle: AppHandle) -> Vec<String> {
let store = tauri_app_handle
.store(TAURI_STORE_DISABLED_APP_LIST_AND_SEARCH_PATH)
.unwrap_or_else(|_| {
@@ -1065,18 +1149,25 @@ pub async fn get_app_search_path<R: Runtime>(tauri_app_handle: AppHandle<R>) ->
}
#[tauri::command]
pub async fn get_app_list<R: Runtime>(
tauri_app_handle: AppHandle<R>,
) -> Result<Vec<Extension>, String> {
pub async fn get_app_list(tauri_app_handle: AppHandle) -> Result<Vec<Extension>, String> {
use crate::util::app_lang::{Lang, get_app_lang};
let search_paths = get_app_search_path(tauri_app_handle.clone()).await;
let apps = list_app_in(search_paths)?;
let mut app_entries = Vec::with_capacity(apps.len());
let lang = get_app_lang().await;
for app in apps {
let name = get_app_name(&app).await;
let name = match lang {
Lang::zh_CN => get_app_name_zh(&app).await,
Lang::en_US => get_app_name_en(&app).await,
};
// filter out Coco-AI
//
// Coco does not have localized app names, so regardless the chosen language, name
// should have value "Coco-AI".
if name.eq(&tauri_app_handle.package_info().name) {
continue;
}
@@ -1202,9 +1293,7 @@ pub async fn get_app_metadata(app_name: String, app_path: String) -> Result<AppM
}
#[tauri::command]
pub async fn reindex_applications<R: Runtime>(
tauri_app_handle: AppHandle<R>,
) -> Result<(), String> {
pub async fn reindex_applications(tauri_app_handle: AppHandle) -> Result<(), String> {
let (tx, rx) = tokio::sync::oneshot::channel();
let reindex_applications_task = ReindexAllApplicationsTask {
tauri_app_handle: tauri_app_handle.clone(),

View File

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

View File

@@ -5,7 +5,6 @@ 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
@@ -54,7 +53,7 @@ impl Default for FileSearchConfig {
}
impl FileSearchConfig {
pub(crate) fn get<R: Runtime>(tauri_app_handle: &AppHandle<R>) -> Self {
pub(crate) fn get(tauri_app_handle: &AppHandle) -> Self {
let store = tauri_app_handle
.store(TAURI_STORE_FILE_SYSTEM_CONFIG)
.unwrap_or_else(|e| {
@@ -185,15 +184,13 @@ impl FileSearchConfig {
// Tauri commands for managing file system configuration
#[tauri::command]
pub async fn get_file_system_config<R: Runtime>(
tauri_app_handle: AppHandle<R>,
) -> FileSearchConfig {
pub async fn get_file_system_config(tauri_app_handle: AppHandle) -> FileSearchConfig {
FileSearchConfig::get(&tauri_app_handle)
}
#[tauri::command]
pub async fn set_file_system_config<R: Runtime>(
tauri_app_handle: AppHandle<R>,
pub async fn set_file_system_config(
tauri_app_handle: AppHandle,
config: FileSearchConfig,
) -> Result<(), String> {
let store = tauri_app_handle

View File

@@ -16,11 +16,9 @@ use crate::extension::{
};
use anyhow::Context;
use std::path::{Path, PathBuf};
use tauri::{AppHandle, Manager, Runtime};
use tauri::{AppHandle, Manager};
pub(crate) fn get_built_in_extension_directory<R: Runtime>(
tauri_app_handle: &AppHandle<R>,
) -> PathBuf {
pub(crate) fn get_built_in_extension_directory(tauri_app_handle: &AppHandle) -> PathBuf {
let mut resource_dir = tauri_app_handle.path().app_data_dir().expect(
"User home directory not found, which should be impossible on desktop environments",
);
@@ -136,8 +134,8 @@ async fn load_built_in_extension(
/// We only read alias/hotkey/enabled from the JSON file, we have ensured that if
/// alias/hotkey is not supported, then it will be `None`. Besides that, no further
/// validation is needed because nothing could go wrong.
pub(crate) async fn list_built_in_extensions<R: Runtime>(
tauri_app_handle: &AppHandle<R>,
pub(crate) async fn list_built_in_extensions(
tauri_app_handle: &AppHandle,
) -> Result<Vec<Extension>, String> {
let dir = get_built_in_extension_directory(tauri_app_handle);
@@ -191,8 +189,8 @@ pub(crate) async fn list_built_in_extensions<R: Runtime>(
Ok(built_in_extensions)
}
pub(super) async fn init_built_in_extension<R: Runtime>(
tauri_app_handle: &AppHandle<R>,
pub(super) async fn init_built_in_extension(
tauri_app_handle: &AppHandle,
extension: &Extension,
search_source_registry: &SearchSourceRegistry,
) -> Result<(), String> {
@@ -233,8 +231,8 @@ pub(crate) fn is_extension_built_in(bundle_id: &ExtensionBundleIdBorrowed<'_>) -
bundle_id.developer.is_none()
}
pub(crate) async fn enable_built_in_extension<R: Runtime>(
tauri_app_handle: &AppHandle<R>,
pub(crate) async fn enable_built_in_extension(
tauri_app_handle: &AppHandle,
bundle_id: &ExtensionBundleIdBorrowed<'_>,
) -> Result<(), String> {
let search_source_registry_tauri_state = tauri_app_handle.state::<SearchSourceRegistry>();
@@ -321,8 +319,8 @@ pub(crate) async fn enable_built_in_extension<R: Runtime>(
Ok(())
}
pub(crate) async fn disable_built_in_extension<R: Runtime>(
tauri_app_handle: &AppHandle<R>,
pub(crate) async fn disable_built_in_extension(
tauri_app_handle: &AppHandle,
bundle_id: &ExtensionBundleIdBorrowed<'_>,
) -> Result<(), String> {
let search_source_registry_tauri_state = tauri_app_handle.state::<SearchSourceRegistry>();
@@ -408,8 +406,8 @@ pub(crate) async fn disable_built_in_extension<R: Runtime>(
Ok(())
}
pub(crate) fn set_built_in_extension_alias<R: Runtime>(
tauri_app_handle: &AppHandle<R>,
pub(crate) fn set_built_in_extension_alias(
tauri_app_handle: &AppHandle,
bundle_id: &ExtensionBundleIdBorrowed<'_>,
alias: &str,
) {
@@ -420,8 +418,8 @@ pub(crate) fn set_built_in_extension_alias<R: Runtime>(
}
}
pub(crate) fn register_built_in_extension_hotkey<R: Runtime>(
tauri_app_handle: &AppHandle<R>,
pub(crate) fn register_built_in_extension_hotkey(
tauri_app_handle: &AppHandle,
bundle_id: &ExtensionBundleIdBorrowed<'_>,
hotkey: &str,
) -> Result<(), String> {
@@ -433,8 +431,8 @@ pub(crate) fn register_built_in_extension_hotkey<R: Runtime>(
Ok(())
}
pub(crate) fn unregister_built_in_extension_hotkey<R: Runtime>(
tauri_app_handle: &AppHandle<R>,
pub(crate) fn unregister_built_in_extension_hotkey(
tauri_app_handle: &AppHandle,
bundle_id: &ExtensionBundleIdBorrowed<'_>,
) -> Result<(), String> {
if bundle_id.extension_id == application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME {
@@ -481,8 +479,8 @@ fn load_extension_from_json_file(
Ok(extension)
}
pub(crate) async fn is_built_in_extension_enabled<R: Runtime>(
tauri_app_handle: &AppHandle<R>,
pub(crate) async fn is_built_in_extension_enabled(
tauri_app_handle: &AppHandle,
bundle_id: &ExtensionBundleIdBorrowed<'_>,
) -> Result<bool, String> {
let search_source_registry_tauri_state = tauri_app_handle.state::<SearchSourceRegistry>();

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,729 @@
//! 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 sub_extensions = [commands, scripts, quicklinks].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()
{
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()
{
return Err(format!(
"invalid sub-extension [{}-{}]: fields [commands/scripts/quicklinks] 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
));
}
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 {
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,
alias: None,
hotkey: None,
enabled: true,
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 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] 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

@@ -0,0 +1,253 @@
use crate::extension::PLUGIN_JSON_FILE_NAME;
use crate::extension::third_party::check::general_check;
use crate::extension::third_party::install::{
filter_out_incompatible_sub_extensions, is_extension_installed,
};
use crate::extension::third_party::{
THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE, get_third_party_extension_directory,
};
use crate::extension::{Extension, canonicalize_relative_icon_path};
use crate::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())?;
// Canonicalize relative icon paths
canonicalize_relative_icon_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

@@ -0,0 +1,224 @@
//! This module contains the code of extension installation.
//!
//!
//! # How
//!
//! Technically, installing an extension involves the following steps:
//!
//! 1. Correct the `plugin.json` JSON if it does not conform to our `struct Extension`
//! definition.
//!
//! 2. Write the extension files to the corresponding location
//!
//! * developer directory
//! * extension directory
//! * assets directory
//! * various assets files, e.g., "icon.png"
//! * plugin.json file
//!
//! 3. Canonicalize the `Extension.icon` fields if they are relative paths
//! (relative to the `assets` directory)
//!
//! 4. Deserialize the `plugin.json` file to a `struct Extension`, and call
//! `THIRD_PARTY_EXTENSIONS_DIRECTORY.add_extension(extension)` to add it to
//! the in-memory extension list.
pub(crate) mod local_extension;
pub(crate) mod store;
use crate::extension::Extension;
use crate::util::platform::Platform;
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;
}
// Filter commands
if let Some(ref mut commands) = extension.commands {
commands.retain(|sub_ext| {
// If platforms is None, the sub-extension is compatible with all platforms
if let Some(ref platforms) = sub_ext.platforms {
platforms.contains(&current_platform)
} else {
true
}
});
}
// Filter scripts
if let Some(ref mut scripts) = extension.scripts {
scripts.retain(|sub_ext| {
// If platforms is None, the sub-extension is compatible with all platforms
if let Some(ref platforms) = sub_ext.platforms {
platforms.contains(&current_platform)
} else {
true
}
});
}
// Filter quicklinks
if let Some(ref mut quicklinks) = extension.quicklinks {
quicklinks.retain(|sub_ext| {
// If platforms is None, the sub-extension is compatible with all platforms
if let Some(ref platforms) = sub_ext.platforms {
platforms.contains(&current_platform)
} else {
true
}
});
}
}
#[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,
alias: None,
hotkey: None,
enabled: true,
settings: 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])),
)];
// Set sub extensions
main_extension.commands = Some(commands);
main_extension.quicklinks = Some(quicklinks);
main_extension.scripts = Some(scripts);
// 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());
}
/// 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()),
)];
// Set sub extensions
main_extension.commands = Some(commands);
main_extension.quicklinks = Some(quicklinks);
main_extension.scripts = Some(scripts);
// 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);
}
// `platforms: None` means all platforms as well
{
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)];
// Set sub extensions
main_extension.commands = Some(commands);
main_extension.quicklinks = Some(quicklinks);
main_extension.scripts = Some(scripts);
// 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);
}
}
}

View File

@@ -1,6 +1,7 @@
//! Extension store related stuff.
use super::LOCAL_QUERY_SOURCE_TYPE;
use super::super::LOCAL_QUERY_SOURCE_TYPE;
use super::is_extension_installed;
use crate::common::document::DataSourceReference;
use crate::common::document::Document;
use crate::common::error::SearchError;
@@ -12,9 +13,13 @@ use crate::extension::Extension;
use crate::extension::PLUGIN_JSON_FILE_NAME;
use crate::extension::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE;
use crate::extension::canonicalize_relative_icon_path;
use crate::extension::third_party::check::general_check;
use crate::extension::third_party::get_third_party_extension_directory;
use crate::extension::third_party::install::filter_out_incompatible_sub_extensions;
use crate::server::http_client::HttpClient;
use crate::util::platform::Platform;
use async_trait::async_trait;
use http::Method;
use reqwest::StatusCode;
use serde_json::Map as JsonObject;
use serde_json::Value as Json;
@@ -152,14 +157,12 @@ pub(crate) async fn search_extension(
.get("developer")
.and_then(|dev| dev.get("id"))
.and_then(|id| id.as_str())
.expect("developer.id should exist")
.to_string();
.expect("developer.id should exist");
let extension_id = source_obj
.get("id")
.and_then(|id| id.as_str())
.expect("extension id should exist")
.to_string();
.expect("extension id should exist");
let installed = is_extension_installed(developer_id, extension_id).await;
source_obj.insert("installed".to_string(), Json::Bool(installed));
@@ -170,12 +173,50 @@ pub(crate) async fn search_extension(
Ok(extensions)
}
async fn is_extension_installed(developer: String, extension_id: String) -> bool {
THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE
.get()
.unwrap()
.extension_exists(&developer, &extension_id)
.await
#[tauri::command]
pub(crate) async fn extension_detail(
id: String,
) -> Result<Option<JsonObject<String, Json>>, String> {
let url = format!("http://dev.infini.cloud:27200/store/extension/{}", id);
let response =
HttpClient::send_raw_request(Method::GET, url.as_str(), None, None, None).await?;
if response.status() == StatusCode::NOT_FOUND {
return Ok(None);
}
let response_dbg_str = format!("{:?}", response);
// The response of an ES style GET request
let mut response: JsonObject<String, Json> = response.json().await.unwrap_or_else(|_e| {
panic!(
"response body of [/store/extension/<ID>] is not a JSON object, response [{:?}]",
response_dbg_str
)
});
let source_json = response.remove("_source").unwrap_or_else(|| {
panic!("field [_source] not found in the JSON returned from [/store/extension/<ID>]")
});
let mut source_obj = match source_json {
Json::Object(obj) => obj,
_ => panic!(
"field [_source] should be a JSON object, but it is not, value: [{}]",
source_json
),
};
let developer_id = match &source_obj["developer"]["id"] {
Json::String(dev) => dev,
_ => {
panic!(
"field [_source.developer.id] should be a string, but it is not, value: [{}]",
source_obj["developer"]["id"]
)
}
};
let installed = is_extension_installed(developer_id, &id).await;
source_obj.insert("installed".to_string(), Json::Bool(installed));
Ok(Some(source_obj))
}
#[tauri::command]
@@ -256,15 +297,40 @@ pub(crate) async fn install_extension_from_store(
e
);
});
let developer_id = extension.developer.clone().expect("developer has been set");
drop(plugin_json);
general_check(&extension)?;
let current_platform = Platform::current();
if let Some(ref platforms) = extension.platforms {
if !platforms.contains(&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);
path.push(developer_id);
path.push(extension_id.as_str());
path
};
@@ -331,11 +397,7 @@ pub(crate) async fn install_extension_from_store(
// 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)?;
THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE
.get()
.unwrap()
.add_extension(extension)
.await;
third_party_ext_list_write_lock.push(extension);
Ok(())
}

View File

@@ -1,7 +1,7 @@
pub(crate) mod store;
pub(crate) mod check;
pub(crate) mod install;
use super::Extension;
use super::ExtensionType;
use super::LOCAL_QUERY_SOURCE_TYPE;
use super::PLUGIN_JSON_FILE_NAME;
use super::alter_extension_json_file;
@@ -18,15 +18,15 @@ use crate::extension::ExtensionBundleIdBorrowed;
use crate::util::platform::Platform;
use async_trait::async_trait;
use borrowme::ToOwned;
use check::general_check;
use function_name::named;
use std::ffi::OsStr;
use std::io::ErrorKind;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::OnceLock;
use tauri::AppHandle;
use tauri::Manager;
use tauri::Runtime;
use tauri::async_runtime;
use tauri_plugin_global_shortcut::GlobalShortcutExt;
use tauri_plugin_global_shortcut::ShortcutState;
@@ -34,9 +34,7 @@ use tokio::fs::read_dir;
use tokio::sync::RwLock;
use tokio::sync::RwLockWriteGuard;
pub(crate) fn get_third_party_extension_directory<R: Runtime>(
tauri_app_handle: &AppHandle<R>,
) -> PathBuf {
pub(crate) fn get_third_party_extension_directory(tauri_app_handle: &AppHandle) -> PathBuf {
let mut app_data_dir = tauri_app_handle.path().app_data_dir().expect(
"User home directory not found, which should be impossible on desktop environments",
);
@@ -45,11 +43,9 @@ pub(crate) fn get_third_party_extension_directory<R: Runtime>(
app_data_dir
}
pub(crate) async fn list_third_party_extensions(
pub(crate) async fn load_third_party_extensions_from_directory(
directory: &Path,
) -> Result<(bool, Vec<Extension>), String> {
let mut found_invalid_extensions = false;
) -> Result<Vec<Extension>, String> {
let mut extensions_dir_iter = read_dir(&directory).await.map_err(|e| e.to_string())?;
let current_platform = Platform::current();
@@ -65,7 +61,6 @@ pub(crate) async fn list_third_party_extensions(
};
let developer_dir_file_type = developer_dir.file_type().await.map_err(|e| e.to_string())?;
if !developer_dir_file_type.is_dir() {
found_invalid_extensions = true;
log::warn!(
"file [{}] under the third party extension directory should be a directory, but it is not",
developer_dir.file_name().display()
@@ -87,14 +82,17 @@ pub(crate) async fn list_third_party_extensions(
let Some(extension_dir) = opt_extension_dir else {
break 'extension;
};
let extension_dir_file_name = extension_dir
.file_name()
.into_string()
.expect("extension directory name should be UTF-8 encoded");
let extension_dir_file_type =
extension_dir.file_type().await.map_err(|e| e.to_string())?;
if !extension_dir_file_type.is_dir() {
found_invalid_extensions = true;
log::warn!(
"invalid extension [{}]: a valid extension should be a directory, but it is not",
extension_dir.file_name().display()
extension_dir_file_name
);
// Skip invalid extension
@@ -109,7 +107,6 @@ pub(crate) async fn list_third_party_extensions(
};
if !plugin_json_file_path.is_file() {
found_invalid_extensions = true;
log::warn!(
"invalid extension: [{}]: extension file [{}] should be a JSON file, but it is not",
extension_dir.file_name().display(),
@@ -126,10 +123,9 @@ pub(crate) async fn list_third_party_extensions(
let mut extension = match serde_json::from_str::<Extension>(&plugin_json_file_content) {
Ok(extension) => extension,
Err(e) => {
found_invalid_extensions = true;
log::warn!(
"invalid extension: [{}]: extension file [{}] is invalid, error: '{}'",
extension_dir.file_name().display(),
"invalid extension: [{}]: cannot parse file [{}] as a [struct Extension], error: '{}'",
extension_dir_file_name,
plugin_json_file_path.display(),
e
);
@@ -137,20 +133,56 @@ pub(crate) async fn list_third_party_extensions(
}
};
// Turn it into an absolute path if it is a valid relative path because frontend code need this.
canonicalize_relative_icon_path(&extension_dir.path(), &mut extension)?;
/* Check starts here */
if extension.id != extension_dir_file_name {
log::warn!(
"extension under [{}:{}] has an ID that is not same as the [{}]",
developer_dir.file_name().display(),
extension_dir_file_name,
extension.id,
);
continue;
}
// Extension should be unique
if extensions.iter().any(|ext: &Extension| {
ext.id == extension.id && ext.developer == extension.developer
}) {
log::warn!(
"an extension with the same bundle ID [ID {}, developer {:?}] already exists, skip this one",
extension.id,
extension.developer
);
continue;
}
if let Err(error_msg) = general_check(&extension) {
log::warn!("{}", error_msg);
if !validate_extension(
&extension,
&extension_dir.file_name(),
&extensions,
current_platform,
) {
found_invalid_extensions = true;
// Skip invalid extension
continue;
}
if let Some(ref platforms) = extension.platforms {
if !platforms.contains(&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)?;
extensions.push(extension);
}
}
@@ -163,203 +195,7 @@ pub(crate) async fn list_third_party_extensions(
.collect::<Vec<_>>()
);
Ok((found_invalid_extensions, extensions))
}
/// Helper function to validate `extension`, return `true` if it is valid.
fn validate_extension(
extension: &Extension,
extension_dir_name: &OsStr,
listed_extensions: &[Extension],
current_platform: Platform,
) -> bool {
if OsStr::new(&extension.id) != extension_dir_name {
log::warn!(
"invalid extension []: id [{}] and extension directory name [{}] do not match",
extension.id,
extension_dir_name.display()
);
return false;
}
// Extension ID should be unique
if listed_extensions.iter().any(|ext| ext.id == extension.id) {
log::warn!(
"invalid extension []: extension with id [{}] already exists",
extension.id,
);
return false;
}
if !validate_extension_or_sub_item(extension) {
return false;
}
// Extension is incompatible
if let Some(ref platforms) = extension.platforms {
if !platforms.contains(&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
Ok(extensions)
}
/// All the third-party extensions will be registered as one search source.
@@ -367,7 +203,7 @@ fn validate_sub_items(extension_id: &str, sub_items: &[Extension]) -> bool {
/// Since some `#[tauri::command]`s need to access it, we store it in a global
/// static variable as well.
#[derive(Debug, Clone)]
pub(super) struct ThirdPartyExtensionsSearchSource {
pub(crate) struct ThirdPartyExtensionsSearchSource {
inner: Arc<ThirdPartyExtensionsSearchSourceInner>,
}
@@ -419,7 +255,7 @@ impl ThirdPartyExtensionsSearchSource {
if event.state() == ShortcutState::Pressed {
async_runtime::spawn(async move {
let result = open(app_handle_clone, on_opened_clone).await;
let result = open(app_handle_clone, on_opened_clone, None).await;
if let Err(msg) = result {
log::warn!(
"failed to open extension [{}], error [{}]",
@@ -508,6 +344,11 @@ impl ThirdPartyExtensionsSearchSource {
}
}
/// Acquire the write lock to the extension list.
pub(crate) async fn write_lock(&self) -> RwLockWriteGuard<'_, Vec<Extension>> {
self.inner.extensions.write().await
}
#[named]
pub(super) async fn enable_extension(
&self,
@@ -622,7 +463,7 @@ impl ThirdPartyExtensionsSearchSource {
/// Initialize the third-party extensions, which literally means
/// enabling/activating the enabled extensions.
pub(super) async fn init(&self, tauri_app_handle: &AppHandle) -> Result<(), String> {
pub(crate) async fn init(&self, tauri_app_handle: &AppHandle) -> Result<(), String> {
let extensions_read_lock = self.inner.extensions.read().await;
for extension in extensions_read_lock.iter().filter(|ext| ext.enabled) {
@@ -680,7 +521,7 @@ impl ThirdPartyExtensionsSearchSource {
if event.state() == ShortcutState::Pressed {
async_runtime::spawn(async move {
let result = open(app_handle_clone, on_opened_clone).await;
let result = open(app_handle_clone, on_opened_clone, None).await;
if let Err(msg) = result {
log::warn!(
"failed to open extension [{:?}], error [{}]",
@@ -793,48 +634,68 @@ impl ThirdPartyExtensionsSearchSource {
.any(|ext| ext.developer.as_deref() == Some(developer) && ext.id == extension_id)
}
/// Add `extension` to the **in-memory** extension list.
pub(crate) async fn add_extension(&self, extension: Extension) {
assert!(
extension.developer.is_some(),
"loaded third party extension should have its developer set"
);
pub(crate) async fn uninstall_extension(
&self,
tauri_app_handle: &AppHandle,
developer: &str,
extension_id: &str,
) -> Result<(), String> {
let mut write_lock = self.inner.extensions.write().await;
let mut write_lock_guard = self.inner.extensions.write().await;
if write_lock_guard
.iter()
.any(|ext| ext.developer == extension.developer && ext.id == extension.id)
{
panic!(
"extension [{}/{}] already installed",
extension
.developer
.as_ref()
.expect("just checked it is Some"),
extension.id
);
}
write_lock_guard.push(extension);
}
/// Remove `extension` from the **in-memory** extension list.
pub(crate) async fn remove_extension(&self, developer: &str, extension_id: &str) -> Extension {
let mut write_lock_guard = self.inner.extensions.write().await;
let Some(index) = write_lock_guard
let Some(index) = write_lock
.iter()
.position(|ext| ext.developer.as_deref() == Some(developer) && ext.id == extension_id)
else {
panic!(
"extension [{}/{}] not installed, but we are trying to remove it",
return Err(format!(
"The extension we are trying to uninstall [{}/{}] does not exist",
developer, extension_id
);
));
};
let deleted_extension = write_lock.remove(index);
let extension_dir = {
let mut path = get_third_party_extension_directory(&tauri_app_handle);
path.push(developer);
path.push(extension_id);
path
};
write_lock_guard.remove(index)
if let Err(e) = tokio::fs::remove_dir_all(extension_dir.as_path()).await {
let error_kind = e.kind();
if error_kind == ErrorKind::NotFound {
// We accept this error because we do want it to not exist. But
// since it is not a state we expect, throw a warning.
log::warn!(
"trying to uninstalling extension [developer {} id {}], but its directory does not exist",
developer,
extension_id
);
} else {
return Err(format!(
"failed to uninstall extension [developer {} id {}] due to error {}",
developer, extension_id, e
));
}
}
// Unregister the extension hotkey, if set.
//
// Unregistering hotkey is the only thing that we will do when we disable
// an extension, so we directly use this function here even though "disabling"
// the extension that one is trying to uninstall does not make too much sense.
Self::_disable_extension(&tauri_app_handle, &deleted_extension).await?;
Ok(())
}
/// Take a point-in-time snapshot at the extension list and return it.
pub(crate) async fn extensions_snapshot(&self) -> Vec<Extension> {
self.inner.extensions.read().await.clone()
}
}
pub(super) static THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE: OnceLock<ThirdPartyExtensionsSearchSource> =
pub(crate) static THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE: OnceLock<ThirdPartyExtensionsSearchSource> =
OnceLock::new();
#[derive(Debug)]
@@ -1062,37 +923,11 @@ pub(crate) async fn uninstall_extension(
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
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(())
.expect("global third party search source not set")
.uninstall_extension(&tauri_app_handle, &developer, &extension_id)
.await
}
#[cfg(test)]

View File

@@ -10,16 +10,14 @@ mod shortcut;
mod util;
use crate::common::register::SearchSourceRegistry;
// use crate::common::traits::SearchSource;
use crate::common::{CHECK_WINDOW_LABEL, MAIN_WINDOW_LABEL, SETTINGS_WINDOW_LABEL};
use crate::server::servers::{load_or_insert_default_server, load_servers_token};
use autostart::{change_autostart, ensure_autostart_state_consistent};
use autostart::change_autostart;
use lazy_static::lazy_static;
use std::sync::Mutex;
use std::sync::OnceLock;
use tauri::async_runtime::block_on;
use tauri::plugin::TauriPlugin;
use tauri::{AppHandle, Emitter, Manager, PhysicalPosition, Runtime, WebviewWindow, WindowEvent};
use tauri::{AppHandle, Emitter, Manager, PhysicalPosition, WebviewWindow, WindowEvent};
use tauri_plugin_autostart::MacosLauncher;
/// Tauri store name
@@ -70,10 +68,8 @@ pub fn run() {
#[cfg(desktop)]
{
app_builder = app_builder.plugin(tauri_plugin_single_instance::init(|_app, argv, _cwd| {
log::debug!("a new app instance was opened with {argv:?} and the deep link event was already triggered");
// when defining deep link schemes at runtime, you must also check `argv` here
}));
app_builder =
app_builder.plugin(tauri_plugin_single_instance::init(|_app, _argv, _cwd| {}));
}
app_builder = app_builder
@@ -130,9 +126,7 @@ pub fn run() {
server::connector::get_connectors_by_server,
search::query_coco_fusion,
assistant::chat_history,
assistant::new_chat,
assistant::chat_create,
assistant::send_message,
assistant::chat_chat,
assistant::session_chat_history,
assistant::open_session_chat,
@@ -145,11 +139,9 @@ pub fn run() {
assistant::assistant_get_multi,
// server::get_coco_server_datasources,
// server::get_coco_server_connectors,
server::websocket::connect_to_server,
server::websocket::disconnect,
get_app_search_source,
server::attachment::upload_attachment,
server::attachment::get_attachment,
server::attachment::get_attachment_by_ids,
server::attachment::delete_attachment,
server::transcription::transcription,
server::system_settings::get_system_settings,
@@ -159,6 +151,7 @@ pub fn run() {
extension::built_in::application::add_app_search_path,
extension::built_in::application::remove_app_search_path,
extension::built_in::application::reindex_applications,
extension::quicklink_link_arguments,
extension::list_extensions,
extension::enable_extension,
extension::disable_extension,
@@ -166,8 +159,10 @@ pub fn run() {
extension::register_extension_hotkey,
extension::unregister_extension_hotkey,
extension::is_extension_enabled,
extension::third_party::store::search_extension,
extension::third_party::store::install_extension_from_store,
extension::third_party::install::store::search_extension,
extension::third_party::install::store::extension_detail,
extension::third_party::install::store::install_extension_from_store,
extension::third_party::install::local_extension::install_local_extension,
extension::third_party::uninstall_extension,
settings::set_allow_self_signature,
settings::get_allow_self_signature,
@@ -179,17 +174,10 @@ pub fn run() {
extension::built_in::file_search::config::set_file_system_config,
server::synthesize::synthesize,
util::file::get_file_icon,
setup::backend_setup,
util::app_lang::update_app_lang,
#[cfg(target_os = "macos")]
setup::toggle_move_to_active_space_attribute,
])
.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");
@@ -197,68 +185,21 @@ pub fn run() {
log::trace!("Dock icon should be hidden now");
}
let registry = SearchSourceRegistry::default();
app.manage(registry); // Store registry in Tauri's app state
app.manage(server::websocket::WebSocketManager::default());
// This has to be called before initializing extensions as doing that
// requires access to the shortcut store, which will be set by this
// function.
shortcut::enable_shortcut(app);
block_on(async {
init(app.handle()).await;
// We want all the extensions here, so no filter condition specified.
match extension::list_extensions(app_handle.clone(), None, None, false).await {
Ok((_found_invalid_extensions, extensions)) => {
// Initializing extension relies on SearchSourceRegistry, so this should
// be executed after `app.manage(registry)`
if let Err(e) =
extension::init_extensions(app_handle.clone(), extensions).await
{
log::error!("initializing extensions failed with error [{}]", e);
}
}
Err(e) => {
log::error!("listing extensions failed with error [{}]", e);
}
}
});
ensure_autostart_state_consistent(app)?;
// app.listen("theme-changed", move |event| {
// if let Ok(payload) = serde_json::from_str::<ThemeChangedPayload>(event.payload()) {
// // switch_tray_icon(app.app_handle(), payload.is_dark_mode);
// log::debug!("Theme changed: is_dark_mode = {}", payload.is_dark_mode);
// }
// });
#[cfg(desktop)]
{
#[cfg(any(windows, target_os = "linux"))]
{
app.deep_link().register("coco")?;
use tauri_plugin_deep_link::DeepLinkExt;
app.deep_link().register_all()?;
}
}
// app.deep_link().on_open_url(|event| {
// dbg!(event.urls());
// });
let main_window = app.get_webview_window(MAIN_WINDOW_LABEL).unwrap();
let settings_window = app.get_webview_window(SETTINGS_WINDOW_LABEL).unwrap();
let check_window = app.get_webview_window(CHECK_WINDOW_LABEL).unwrap();
/* ----------- This code must be executed on the main thread and must not be relocated. ----------- */
let app_handle = app.app_handle();
let main_window = app_handle.get_webview_window(MAIN_WINDOW_LABEL).unwrap();
let settings_window = app_handle
.get_webview_window(SETTINGS_WINDOW_LABEL)
.unwrap();
let check_window = app_handle.get_webview_window(CHECK_WINDOW_LABEL).unwrap();
setup::default(
app,
app_handle,
main_window.clone(),
settings_window.clone(),
check_window.clone(),
);
/* ----------- This code must be executed on the main thread and must not be relocated. ----------- */
Ok(())
})
@@ -293,7 +234,7 @@ pub fn run() {
});
}
pub async fn init<R: Runtime>(app_handle: &AppHandle<R>) {
pub async fn init(app_handle: &AppHandle) {
// Await the async functions to load the servers and tokens
if let Err(err) = load_or_insert_default_server(app_handle).await {
log::error!("Failed to load servers: {}", err);
@@ -317,7 +258,7 @@ pub async fn init<R: Runtime>(app_handle: &AppHandle<R>) {
}
#[tauri::command]
async fn show_coco<R: Runtime>(app_handle: AppHandle<R>) {
async fn show_coco(app_handle: AppHandle) {
if let Some(window) = app_handle.get_webview_window(MAIN_WINDOW_LABEL) {
move_window_to_active_monitor(&window);
@@ -330,7 +271,7 @@ async fn show_coco<R: Runtime>(app_handle: AppHandle<R>) {
}
#[tauri::command]
async fn hide_coco<R: Runtime>(app: AppHandle<R>) {
async fn hide_coco(app: AppHandle) {
if let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) {
if let Err(err) = window.hide() {
log::error!("Failed to hide the window: {}", err);
@@ -342,7 +283,7 @@ async fn hide_coco<R: Runtime>(app: AppHandle<R>) {
}
}
fn move_window_to_active_monitor<R: Runtime>(window: &WebviewWindow<R>) {
fn move_window_to_active_monitor(window: &WebviewWindow) {
//dbg!("Moving window to active monitor");
// Try to get the available monitors, handle failure gracefully
let available_monitors = match window.available_monitors() {

View File

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

View File

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

View File

@@ -4,7 +4,7 @@ use crate::server::servers::{
get_server_by_id, persist_servers, persist_servers_token, save_access_token, save_server,
try_register_server_to_search_source,
};
use tauri::{AppHandle, Runtime};
use tauri::AppHandle;
#[allow(dead_code)]
fn request_access_token_url(request_id: &str) -> String {
@@ -13,8 +13,8 @@ fn request_access_token_url(request_id: &str) -> String {
}
#[tauri::command]
pub async fn handle_sso_callback<R: Runtime>(
app_handle: AppHandle<R>,
pub async fn handle_sso_callback(
app_handle: AppHandle,
server_id: String,
request_id: String,
code: String,

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

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

View File

@@ -11,4 +11,3 @@ 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, Runtime};
use tauri::AppHandle;
#[tauri::command]
pub async fn get_user_profiles<R: Runtime>(
_app_handle: AppHandle<R>,
pub async fn get_user_profiles(
_app_handle: AppHandle,
server_id: String,
) -> Result<UserProfile, String> {
// Use the generic GET method from HttpClient

View File

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

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, Runtime, command};
use tauri::{AppHandle, Emitter, command};
#[command]
pub async fn synthesize<R: Runtime>(
app_handle: AppHandle<R>,
pub async fn synthesize(
app_handle: AppHandle,
client_id: String,
server_id: String,
voice: String,

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,6 @@
//! credits to: https://github.com/ayangweb/ayangweb-EcoPaste/blob/169323dbe6365ffe4abb64d867439ed2ea84c6d1/src-tauri/src/core/setup/mac.rs
use cocoa::appkit::NSWindow;
use tauri::Manager;
use tauri::{App, AppHandle, Emitter, EventTarget, WebviewWindow};
use tauri::{AppHandle, Emitter, EventTarget, WebviewWindow};
use tauri_nspanel::{WebviewWindowExt, cocoa::appkit::NSWindowCollectionBehavior, panel_delegate};
use crate::common::MAIN_WINDOW_LABEL;
@@ -16,7 +14,7 @@ const WINDOW_MOVED_EVENT: &str = "tauri://move";
const WINDOW_RESIZED_EVENT: &str = "tauri://resize";
pub fn platform(
_app: &mut App,
_tauri_app_handle: &AppHandle,
main_window: WebviewWindow,
_settings_window: WebviewWindow,
_check_window: WebviewWindow,
@@ -30,7 +28,7 @@ pub fn platform(
// Do not steal focus from other windows
panel.set_style_mask(NSWindowStyleMaskNonActivatingPanel);
// Share the window across all desktop spaces and full screen
// Open the window in the active workspace and full screen
panel.set_collection_behaviour(
NSWindowCollectionBehavior::NSWindowCollectionBehaviorMoveToActiveSpace
| NSWindowCollectionBehavior::NSWindowCollectionBehaviorStationary
@@ -81,50 +79,3 @@ pub fn platform(
// Set the delegate object for the window to handle window events
panel.set_delegate(delegate);
}
/// Change NS window attribute between `NSWindowCollectionBehaviorCanJoinAllSpaces`
/// and `NSWindowCollectionBehaviorMoveToActiveSpace` accordingly.
///
/// NOTE: this tauri command is not async because we should run it in the main
/// thread, or `ns_window.setCollectionBehavior_(collection_behavior)` would lead
/// to UB.
#[tauri::command]
pub(crate) fn toggle_move_to_active_space_attribute(tauri_app_hanlde: AppHandle) {
use cocoa::appkit::NSWindowCollectionBehavior;
use cocoa::base::id;
let main_window = tauri_app_hanlde
.get_webview_window(MAIN_WINDOW_LABEL)
.unwrap();
let ns_window = main_window.ns_window().unwrap() as id;
let mut collection_behavior = unsafe { ns_window.collectionBehavior() };
let join_all_spaces = collection_behavior
.contains(NSWindowCollectionBehavior::NSWindowCollectionBehaviorCanJoinAllSpaces);
let move_to_active_space = collection_behavior
.contains(NSWindowCollectionBehavior::NSWindowCollectionBehaviorMoveToActiveSpace);
match (join_all_spaces, move_to_active_space) {
(true, false) => {
collection_behavior
.remove(NSWindowCollectionBehavior::NSWindowCollectionBehaviorCanJoinAllSpaces);
collection_behavior
.insert(NSWindowCollectionBehavior::NSWindowCollectionBehaviorMoveToActiveSpace);
}
(false, true) => {
collection_behavior
.remove(NSWindowCollectionBehavior::NSWindowCollectionBehaviorMoveToActiveSpace);
collection_behavior
.insert(NSWindowCollectionBehavior::NSWindowCollectionBehaviorCanJoinAllSpaces);
}
_ => {
panic!(
"invalid NS window attribute, NSWindowCollectionBehaviorCanJoinAllSpaces is set [{}], NSWindowCollectionBehaviorMoveToActiveSpace is set [{}]",
join_all_spaces, move_to_active_space
);
}
}
unsafe {
ns_window.setCollectionBehavior_(collection_behavior);
}
}

View File

@@ -1,4 +1,9 @@
use tauri::{App, WebviewWindow};
use crate::GLOBAL_TAURI_APP_HANDLE;
use crate::autostart;
use crate::common::register::SearchSourceRegistry;
use crate::util::app_lang::update_app_lang;
use std::sync::OnceLock;
use tauri::{AppHandle, Manager, WebviewWindow};
#[cfg(target_os = "macos")]
mod mac;
@@ -19,7 +24,7 @@ pub use windows::*;
pub use linux::*;
pub fn default(
app: &mut App,
tauri_app_handle: &AppHandle,
main_window: WebviewWindow,
settings_window: WebviewWindow,
check_window: WebviewWindow,
@@ -29,9 +34,66 @@ pub fn default(
main_window.open_devtools();
platform(
app,
tauri_app_handle,
main_window.clone(),
settings_window.clone(),
check_window.clone(),
);
}
/// Use this variable to track if tauri command `backend_setup()` gets called
/// by the frontend.
pub(super) static BACKEND_SETUP_FUNC_INVOKED: OnceLock<()> = OnceLock::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]
#[function_name::named]
pub(crate) async fn backend_setup(tauri_app_handle: AppHandle, app_lang: String) {
if BACKEND_SETUP_FUNC_INVOKED.get().is_some() {
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.
crate::shortcut::enable_shortcut(&tauri_app_handle);
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_FUNC_INVOKED
.set(())
.unwrap_or_else(|_| panic!("tauri command {}() gets called twice!", function_name!()));
}

View File

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

View File

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

View File

@@ -4,7 +4,6 @@
//! So we duplicate it here **in the MEMORY** and expose a setter method to the
//! frontend so that the value can be updated and stay update-to-date.
use function_name::named;
use tokio::sync::RwLock;
#[derive(Debug, Clone, Copy, PartialEq)]
@@ -23,6 +22,10 @@ impl std::fmt::Display for Lang {
}
}
/// Frontend code uses "en" and "zh" to represent the Application language.
///
/// This impl is not meant to be used as a parser for locale strings such as
/// "en_US" or "zh_CN".
impl std::str::FromStr for Lang {
type Err = String;
@@ -38,16 +41,13 @@ impl std::str::FromStr for Lang {
/// Cache the language config in memory.
static APP_LANG: RwLock<Option<Lang>> = RwLock::const_new(None);
/// Frontend code uses this interface to update the in-memory cached `APP_LANG` config.
#[named]
/// Update the in-memory cached `APP_LANG` config.
#[tauri::command]
pub(crate) async fn update_app_lang(lang: String) {
let app_lang = lang.parse::<Lang>().unwrap_or_else(|e| {
panic!(
"frontend code passes an invalid argument [{}] to interface [{}], parsing error [{}]",
lang,
function_name!(),
e
"invalid argument [{}], could not parse it to [struct Lang], parsing error [{}]",
lang, e
)
});

View File

@@ -1,10 +1,11 @@
pub(crate) mod app_lang;
pub(crate) mod file;
pub(crate) mod platform;
pub(crate) mod system_lang;
pub(crate) mod updater;
use std::{path::Path, process::Command};
use tauri::{AppHandle, Runtime};
use tauri::AppHandle;
use tauri_plugin_shell::ShellExt;
/// We use this env variable to determine the DE on Linux.
@@ -88,7 +89,7 @@ fn get_linux_desktop_environment() -> Option<LinuxDesktopEnvironment> {
//
// tauri_plugin_shell::open() is deprecated, but we still use it.
#[allow(deprecated)]
pub async fn open<R: Runtime>(app_handle: AppHandle<R>, path: String) -> Result<(), String> {
pub async fn open(app_handle: AppHandle, path: String) -> Result<(), String> {
if cfg!(target_os = "linux") {
let borrowed_path = Path::new(&path);
if let Some(file_extension) = borrowed_path.extension() {

View File

@@ -1,8 +1,22 @@
use derive_more::Display;
use serde::{Deserialize, Serialize};
use std::borrow::Cow;
use strum::EnumCount;
use strum::VariantArray;
#[derive(Debug, Deserialize, Serialize, Copy, Clone, Hash, PartialEq, Eq, Display)]
#[derive(
Debug,
Deserialize,
Serialize,
Copy,
Clone,
Hash,
PartialEq,
Eq,
Display,
EnumCount,
VariantArray,
)]
#[serde(rename_all(serialize = "lowercase", deserialize = "lowercase"))]
pub(crate) enum Platform {
#[display("macOS")]
@@ -18,7 +32,7 @@ impl Platform {
pub(crate) fn current() -> Platform {
let os_str = std::env::consts::OS;
serde_plain::from_str(os_str).unwrap_or_else(|_e| {
panic!("std::env::consts::OS is [{}], which is not a valid value for [enum Platform], valid values: ['macos', 'linux', 'windows']", os_str)
panic!("std::env::consts::OS is [{}], which is not a valid value for [enum Platform], valid values: {:?}", os_str, Self::VARIANTS.iter().map(|platform|platform.to_string()).collect::<Vec<String>>());
})
}
@@ -31,4 +45,17 @@ 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.
#[cfg(test)] // currently, only used in tests
pub(crate) fn all() -> std::collections::HashSet<Self> {
Platform::VARIANTS.into_iter().copied().collect()
}
}

View File

@@ -0,0 +1,13 @@
use sys_locale::get_locale;
/// 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.
pub(crate) fn get_system_lang() -> String {
// 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

@@ -15,7 +15,7 @@
{
"label": "main",
"title": "Coco AI",
"url": "/ui",
"url": "index.html/#/ui",
"height": 590,
"width": 680,
"decorations": false,
@@ -39,7 +39,7 @@
{
"label": "settings",
"title": "Coco AI Settings",
"url": "/ui/settings",
"url": "index.html/#/ui/settings",
"width": 1000,
"minWidth": 1000,
"height": 700,
@@ -59,7 +59,7 @@
{
"label": "check",
"title": "Coco AI Update",
"url": "/ui/check",
"url": "index.html/#/ui/check",
"width": 340,
"minWidth": 340,
"height": 260,
@@ -126,11 +126,9 @@
"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

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

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

View File

@@ -34,8 +34,4 @@ export function show_check(): Promise<void> {
export function hide_check(): Promise<void> {
return invoke('hide_check');
}
export function toggle_move_to_active_space_attribute(): Promise<void> {
return invoke('toggle_move_to_active_space_attribute');
}

View File

@@ -0,0 +1,53 @@
import { useConnectStore } from "@/stores/connectStore";
import { SETTINGS_WINDOW_LABEL } from "@/constants";
import platformAdapter from "@/utils/platformAdapter";
import { useAuthStore } from "@/stores/authStore";
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);
setCurrentService(service);
return;
}
// current refresh
const windowLabel = await platformAdapter.getCurrentWindowLabel();
return windowLabel === SETTINGS_WINDOW_LABEL
? setCloudSelectService(service)
: setCurrentService(service);
}
export async function handleLogout(serverId?: string) {
const setIsCurrentLogin = useAuthStore.getState().setIsCurrentLogin;
const { serverList, setServerList } = useConnectStore.getState();
const service = await getCurrentWindowService();
const id = serverId || service?.id;
if (!id) return;
// Update the status first
setIsCurrentLogin(false);
if (service?.id === id) {
await setCurrentWindowService({ ...service, profile: null }, true);
}
const updatedServerList = serverList.map((server) =>
server.id === id ? { ...server, profile: null } : server
);
setServerList(updatedServerList);
}

View File

@@ -57,8 +57,6 @@ export const AssistantFetcher = ({
let assistantList = response?.hits?.hits ?? [];
console.log("assistantList", assistantList);
if (
!currentAssistant?._id ||
currentService?.id !== lastServerId.current

View File

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

View File

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

View File

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

View File

@@ -7,12 +7,12 @@ import PinIcon from "@/icons/Pin";
import WindowsFullIcon from "@/icons/WindowsFull";
import { useAppStore } from "@/stores/appStore";
import type { Chat } from "@/types/chat";
import platformAdapter from "@/utils/platformAdapter";
import VisibleKey from "../Common/VisibleKey";
import { useShortcutsStore } from "@/stores/shortcutsStore";
import { HISTORY_PANEL_ID } from "@/constants";
import { AssistantList } from "./AssistantList";
import { ServerList } from "./ServerList";
import { useTogglePin } from "@/hooks/useTogglePin";
interface ChatHeaderProps {
clearChat: () => void;
@@ -35,12 +35,22 @@ export function ChatHeader({
showChatHistory = true,
assistantIDs,
}: ChatHeaderProps) {
const { isTauri } = useAppStore();
const { isPinned, togglePin } = useTogglePin();
const { isPinned, setIsPinned, isTauri } = useAppStore();
const { historicalRecords, newSession, fixedWindow, external } =
useShortcutsStore();
const togglePin = async () => {
try {
const newPinned = !isPinned;
await platformAdapter.setAlwaysOnTop(newPinned);
setIsPinned(newPinned);
} 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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -2,11 +2,6 @@ import { FC, memo, useCallback, useEffect, useState } from "react";
import { Copy } from "lucide-react";
import { useTranslation } from "react-i18next";
import { v4 as uuidv4 } from "uuid";
import {
getCurrent as getCurrentDeepLinkUrls,
onOpenUrl,
} from "@tauri-apps/plugin-deep-link";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { UserProfile } from "./UserProfile";
import { OpenURLWithBrowser } from "@/utils";
@@ -14,23 +9,27 @@ import { useConnectStore } from "@/stores/connectStore";
import { useAppStore } from "@/stores/appStore";
import { copyToClipboard } from "@/utils";
import platformAdapter from "@/utils/platformAdapter";
import { handleLogout } from "@/commands/servers";
import { useServers } from "@/hooks/useServers";
interface ServiceAuthProps {
setRefreshLoading: (loading: boolean) => void;
refreshClick: (id: string) => void;
refreshClick: (id: string, callback?: () => void) => void;
}
const ServiceAuth = memo(
({ setRefreshLoading, refreshClick }: ServiceAuthProps) => {
const { t } = useTranslation();
const language = useAppStore((state) => state.language);
const addError = useAppStore((state) => state.addError);
const ssoRequestID = useAppStore((state) => state.ssoRequestID);
const setSSORequestID = useAppStore((state) => state.setSSORequestID);
const addError = useAppStore((state) => state.addError);
const cloudSelectService = useConnectStore(
(state) => state.cloudSelectService
);
const currentService = useConnectStore((state) => state.currentService);
const { logoutServer } = useServers();
const [loading, setLoading] = useState(false);
@@ -41,7 +40,7 @@ const ServiceAuth = memo(
setSSORequestID(requestID);
// Generate the login URL with the current appUid
const url = `${currentService?.auth_provider?.sso?.url}/?provider=${currentService?.id}&product=coco&request_id=${requestID}`;
const url = `${cloudSelectService?.auth_provider?.sso?.url}/?provider=${cloudSelectService?.id}&product=coco&request_id=${requestID}`;
console.log("Open SSO link, requestID:", ssoRequestID, url);
@@ -50,121 +49,43 @@ const ServiceAuth = memo(
// Start loading state
setLoading(true);
}, [ssoRequestID, loading, currentService]);
}, [ssoRequestID, loading, cloudSelectService]);
const onLogout = useCallback((id: string) => {
setRefreshLoading(true);
platformAdapter
.commands("logout_coco_server", id)
.then((res: any) => {
console.log("logout_coco_server", id, JSON.stringify(res));
handleLogout(id);
})
.finally(() => {
const onLogout = useCallback(
(id: string) => {
setRefreshLoading(true);
logoutServer(id).finally(() => {
setRefreshLoading(false);
});
}, []);
const handleOAuthCallback = useCallback(
async (code: string | null, serverId: string | null) => {
if (!code || !serverId) {
addError("No authorization code received");
return;
}
try {
console.log("Handling OAuth callback:", { code, serverId });
await platformAdapter.commands("handle_sso_callback", {
serverId: serverId, // Make sure 'server_id' is the correct argument
requestId: ssoRequestID, // Make sure 'request_id' is the correct argument
code: code,
});
if (serverId != null) {
refreshClick(serverId);
}
getCurrentWindow().setFocus();
} catch (e) {
console.error("Sign in failed:", e);
} finally {
setLoading(false);
}
},
[ssoRequestID]
[logoutServer]
);
const handleUrl = (url: string) => {
try {
const urlObject = new URL(url.trim());
console.log("handle urlObject:", urlObject);
// pass request_id and check with local, if the request_id are same, then continue
const reqId = urlObject.searchParams.get("request_id");
const code = urlObject.searchParams.get("code");
if (reqId != ssoRequestID) {
console.log("Request ID not matched, skip");
addError("Request ID not matched, skip");
return;
}
const serverId = currentService?.id;
handleOAuthCallback(code, serverId);
} catch (err) {
console.error("Failed to parse URL:", err);
addError("Invalid URL format: " + err);
}
};
// Fetch the initial deep link intent
// handle oauth success event
useEffect(() => {
// Function to handle pasted URL
const handlePaste = (event: any) => {
const pastedText = event.clipboardData.getData("text").trim();
console.log("handle paste text:", pastedText);
if (isValidCallbackUrl(pastedText)) {
// Handle the URL as if it's a deep link
console.log("handle callback on paste:", pastedText);
handleUrl(pastedText);
}
};
// Function to check if the pasted URL is valid for our deep link scheme
const isValidCallbackUrl = (url: string) => {
return url && url.startsWith("coco://oauth_callback");
};
// Adding event listener for paste events
document.addEventListener("paste", handlePaste);
getCurrentDeepLinkUrls()
.then((urls) => {
console.log("URLs:", urls);
if (urls && urls.length > 0) {
if (isValidCallbackUrl(urls[0].trim())) {
handleUrl(urls[0]);
}
const unlistenOAuth = platformAdapter.listenEvent(
"oauth_success",
(event) => {
const { serverId } = event.payload;
if (serverId) {
refreshClick(serverId, () => {
setLoading(false);
});
addError(language === "zh" ? "登录成功" : "Login Success", "info");
}
})
.catch((err) => {
console.error("Failed to get initial URLs:", err);
addError("Failed to get initial URLs: " + err);
});
const unlisten = onOpenUrl((urls) => handleUrl(urls[0]));
}
);
return () => {
unlisten.then((fn) => fn());
document.removeEventListener("paste", handlePaste);
unlistenOAuth.then((fn) => fn());
};
}, [ssoRequestID]);
}, [refreshClick]);
useEffect(() => {
setLoading(false);
}, [currentService]);
}, [cloudSelectService]);
if (!currentService?.auth_provider?.sso?.url) {
if (!cloudSelectService?.auth_provider?.sso?.url) {
return null;
}
@@ -173,10 +94,10 @@ const ServiceAuth = memo(
<h2 className="text-lg font-medium text-gray-900 dark:text-white mb-4">
{t("cloud.accountInfo")}
</h2>
{currentService?.profile ? (
{cloudSelectService?.profile ? (
<UserProfile
server={currentService?.id}
userInfo={currentService?.profile}
server={cloudSelectService?.id}
userInfo={cloudSelectService?.profile}
onLogout={onLogout}
/>
) : (
@@ -190,7 +111,7 @@ const ServiceAuth = memo(
onCancel={() => setLoading(false)}
onCopy={() => {
copyToClipboard(
`${currentService?.auth_provider?.sso?.url}/?provider=${currentService?.id}&product=coco&request_id=${ssoRequestID}`
`${cloudSelectService?.auth_provider?.sso?.url}/?provider=${cloudSelectService?.id}&product=coco&request_id=${ssoRequestID}`
);
}}
/>
@@ -201,7 +122,7 @@ const ServiceAuth = memo(
<button
className="text-xs text-[#0096FB] dark:text-blue-400 block"
onClick={() =>
OpenURLWithBrowser(currentService?.provider?.eula)
OpenURLWithBrowser(cloudSelectService?.provider?.eula)
}
>
{t("cloud.eula")}
@@ -215,7 +136,9 @@ const ServiceAuth = memo(
<button
className="text-xs text-[#0096FB] dark:text-blue-400 block"
onClick={() =>
OpenURLWithBrowser(currentService?.provider?.privacy_policy)
OpenURLWithBrowser(
cloudSelectService?.provider?.privacy_policy
)
}
>
{t("cloud.privacyPolicy")}

View File

@@ -6,13 +6,13 @@ import { useConnectStore } from "@/stores/connectStore";
interface ServiceBannerProps {}
const ServiceBanner = memo(({}: ServiceBannerProps) => {
const currentService = useConnectStore((state) => state.currentService);
const cloudSelectService = useConnectStore((state) => state.cloudSelectService);
return (
<div className="w-full rounded-[4px] bg-[rgba(229,229,229,1)] dark:bg-gray-800 mb-6">
<img
width="100%"
src={currentService?.provider?.banner || bannerImg}
src={cloudSelectService?.provider?.banner || bannerImg}
alt="banner"
onError={(e) => {
const target = e.target as HTMLImageElement;

View File

@@ -1,4 +1,4 @@
import { memo, useCallback } from "react";
import { memo } from "react";
import { Globe, RefreshCcw, Trash2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import clsx from "clsx";
@@ -7,90 +7,64 @@ import Tooltip from "@/components/Common/Tooltip";
import SettingsToggle from "@/components/Settings/SettingsToggle";
import { OpenURLWithBrowser } from "@/utils";
import { useConnectStore } from "@/stores/connectStore";
import platformAdapter from "@/utils/platformAdapter";
import { useServers } from "@/hooks/useServers";
interface ServiceHeaderProps {
refreshLoading?: boolean;
refreshClick: (id: string) => void;
fetchServers: (force: boolean) => Promise<void>;
}
const ServiceHeader = memo(
({ refreshLoading, refreshClick, fetchServers }: ServiceHeaderProps) => {
({ refreshLoading, refreshClick }: ServiceHeaderProps) => {
const { t } = useTranslation();
const currentService = useConnectStore((state) => state.currentService);
const setCurrentService = useConnectStore(
(state) => state.setCurrentService
);
const cloudSelectService = useConnectStore((state) => state.cloudSelectService);
const enable_coco_server = useCallback(
async (enabled: boolean) => {
if (enabled) {
await platformAdapter.commands("enable_server", currentService?.id);
} else {
await platformAdapter.commands("disable_server", currentService?.id);
}
setCurrentService({ ...currentService, enabled });
await fetchServers(false);
},
[currentService?.id]
);
const removeServer = (id: string) => {
platformAdapter.commands("remove_coco_server", id).then((res: any) => {
console.log("remove_coco_server", id, JSON.stringify(res));
fetchServers(true).then((r) => {
console.log("fetchServers", r);
});
});
};
const { enableServer, removeServer } = useServers();
return (
<div className="flex items-center justify-between mb-4">
<div className="flex items-center space-x-3">
<Tooltip content={currentService?.endpoint}>
<Tooltip content={cloudSelectService?.endpoint}>
<div className="flex items-center text-gray-900 dark:text-white font-medium cursor-pointer">
{currentService?.name}
{cloudSelectService?.name}
</div>
</Tooltip>
</div>
<div className="flex items-center gap-2">
<SettingsToggle
checked={currentService?.enabled}
checked={cloudSelectService?.enabled}
className={clsx({
"bg-red-600 focus:ring-red-500": !currentService?.enabled,
"bg-red-600 focus:ring-red-500": !cloudSelectService?.enabled,
})}
label={
currentService?.enabled
cloudSelectService?.enabled
? t("cloud.enable_server")
: t("cloud.disable_server")
}
onChange={enable_coco_server}
onChange={enableServer}
/>
<button
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 rounded-[6px] bg-white dark:bg-gray-800 border border-[rgba(228,229,239,1)] dark:border-gray-700"
onClick={() =>
OpenURLWithBrowser(currentService?.provider?.website)
OpenURLWithBrowser(cloudSelectService?.provider?.website)
}
>
<Globe className="w-3.5 h-3.5" />
</button>
<button
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 rounded-[6px] bg-white dark:bg-gray-800 border border-[rgba(228,229,239,1)] dark:border-gray-700"
onClick={() => refreshClick(currentService?.id)}
onClick={() => refreshClick(cloudSelectService?.id)}
>
<RefreshCcw
className={`w-3.5 h-3.5 ${refreshLoading ? "animate-spin" : ""}`}
/>
</button>
{!currentService?.builtin && (
{!cloudSelectService?.builtin && (
<button
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 rounded-[6px] bg-white dark:bg-gray-800 border border-[rgba(228,229,239,1)] dark:border-gray-700"
onClick={() => removeServer(currentService?.id)}
onClick={() => removeServer(cloudSelectService?.id)}
>
<Trash2 className="w-3.5 h-3.5 text-[#ff4747]" />
</button>

View File

@@ -7,11 +7,10 @@ import ServiceMetadata from "./ServiceMetadata";
interface ServiceInfoProps {
refreshLoading?: boolean;
refreshClick: (id: string) => void;
fetchServers: (force: boolean) => Promise<void>;
}
const ServiceInfo = memo(
({ refreshLoading, refreshClick, fetchServers }: ServiceInfoProps) => {
({ refreshLoading, refreshClick }: ServiceInfoProps) => {
return (
<>
<ServiceBanner />
@@ -19,7 +18,6 @@ const ServiceInfo = memo(
<ServiceHeader
refreshLoading={refreshLoading}
refreshClick={refreshClick}
fetchServers={fetchServers}
/>
<ServiceMetadata />

View File

@@ -6,25 +6,25 @@ import { useConnectStore } from "@/stores/connectStore";
interface ServiceMetadataProps {}
const ServiceMetadata = memo(({}: ServiceMetadataProps) => {
const currentService = useConnectStore((state) => state.currentService);
const cloudSelectService = useConnectStore((state) => state.cloudSelectService);
return (
<div className="mb-8">
<div className="text-sm text-gray-500 dark:text-gray-400 mb-2 flex">
<span className="flex items-center gap-1">
<PackageOpen className="w-4 h-4" /> {currentService?.provider?.name}
<PackageOpen className="w-4 h-4" /> {cloudSelectService?.provider?.name}
</span>
<span className="mx-4">|</span>
<span className="flex items-center gap-1">
<GitFork className="w-4 h-4" /> {currentService?.version?.number}
<GitFork className="w-4 h-4" /> {cloudSelectService?.version?.number}
</span>
<span className="mx-4">|</span>
<span className="flex items-center gap-1">
<CalendarSync className="w-4 h-4" /> {currentService?.updated}
<CalendarSync className="w-4 h-4" /> {cloudSelectService?.updated}
</span>
</div>
<p className="text-gray-600 dark:text-gray-300 leading-relaxed">
{currentService?.provider?.description}
{cloudSelectService?.provider?.description}
</p>
</div>
);

View File

@@ -20,13 +20,15 @@ interface ServerGroups {
export const Sidebar = forwardRef<{ refreshData: () => void }, SidebarProps>(
({ setIsConnect, serverList }, _ref) => {
const { t } = useTranslation();
const currentService = useConnectStore((state) => state.currentService);
const setCurrentService = useConnectStore(
(state) => state.setCurrentService
);
const cloudSelectService = useConnectStore((state) => {
return state.cloudSelectService;
});
const setCloudSelectService = useConnectStore((state) => {
return state.setCloudSelectService;
});
const selectService = (item: Server) => {
setCurrentService(item);
setCloudSelectService(item);
setIsConnect(true);
};
@@ -41,7 +43,7 @@ export const Sidebar = forwardRef<{ refreshData: () => void }, SidebarProps>(
// Extracted server item rendering
const renderServerItem = useCallback(
(item: Server) => {
const isSelected = currentService?.id === item.id;
const isSelected = cloudSelectService?.id === item.id;
return (
<div
key={item.id}
@@ -72,7 +74,7 @@ export const Sidebar = forwardRef<{ refreshData: () => void }, SidebarProps>(
</div>
);
},
[currentService]
[cloudSelectService]
);
const { builtinServers, customServers } = useMemo(() => {

View File

@@ -12,7 +12,6 @@ interface UserProfileProps {
export function UserProfile({ server, userInfo, onLogout }: UserProfileProps) {
const handleLogout = () => {
onLogout(server);
console.log("Logout", server);
};
const [imageLoadError, setImageLoadError] = useState(false);

View File

@@ -18,7 +18,9 @@ const FileIcon: FC<FileIconProps> = (props) => {
.then(setIconName);
});
return <FontIcon name={iconName} className={twMerge("size-8", className)} />;
return (
<FontIcon name={iconName} className={twMerge("min-w-8 h-8", className)} />
);
};
export default FileIcon;

View File

@@ -1,5 +1,3 @@
import { useAppStore } from "@/stores/appStore";
import logoImg from "@/assets/icon.svg";
import { FC } from "react";
interface FontIconProps {
@@ -9,17 +7,11 @@ interface FontIconProps {
}
const FontIcon: FC<FontIconProps> = ({ name, className, style, ...rest }) => {
const isTauri = useAppStore((state) => state.isTauri);
if (isTauri) {
return (
<svg className={`icon ${className || ""}`} style={style} {...rest}>
<use xlinkHref={`#${name}`} />
</svg>
);
} else {
return <img src={logoImg} className={className} alt={"coco"} />;
}
return (
<svg className={`icon ${className || ""}`} style={style} {...rest}>
<use xlinkHref={`#${name}`} />
</svg>
);
};
export default FontIcon;

View File

@@ -19,7 +19,6 @@ import source_default_dark_img from "@/assets/images/source_default_dark.png";
import { useThemeStore } from "@/stores/themeStore";
import platformAdapter from "@/utils/platformAdapter";
import FontIcon from "../Icons/FontIcon";
import { useTogglePin } from "@/hooks/useTogglePin";
interface FooterProps {
setIsPinnedWeb?: (value: boolean) => void;
@@ -38,16 +37,28 @@ export default function Footer({ setIsPinnedWeb }: FooterProps) {
const isDark = useThemeStore((state) => state.isDark);
const { isTauri } = useAppStore();
const { isPinned, togglePin } = useTogglePin({
onPinChange: setIsPinnedWeb,
});
const { isTauri, isPinned, setIsPinned } = useAppStore();
const { setVisible, updateInfo, skipVersions } = useUpdateStore();
const { fixedWindow, modifierKey } = useShortcutsStore();
const setWindowAlwaysOnTop = useCallback(async (isPinned: boolean) => {
setIsPinnedWeb?.(isPinned);
return platformAdapter.setAlwaysOnTop(isPinned);
}, []);
const togglePin = async () => {
try {
const newPinned = !isPinned;
await setWindowAlwaysOnTop(newPinned);
setIsPinned(newPinned);
} catch (err) {
console.error("Failed to toggle window pin state:", err);
setIsPinned(isPinned);
}
};
const openSetting = useCallback(() => {
return platformAdapter.emitEvent("open_settings", "");
}, []);

View File

@@ -78,6 +78,10 @@ const VisibleKey: FC<VisibleKeyProps> = (props) => {
return "→";
}
if (shortcut === "enter") {
return "↩︎";
}
return shortcut;
};

View File

@@ -2,18 +2,21 @@ import React from "react";
import { Send } from "lucide-react";
import StopIcon from "@/icons/Stop";
import clsx from "clsx";
import { SendMessageParams } from "../Assistant/Chat";
import { getUploadedAttachmentsId, isAttachmentsUploaded } from "@/utils";
import VisibleKey from "../Common/VisibleKey";
interface ChatIconsProps {
lineCount: number;
isChatMode: boolean;
curChatEnd: boolean;
inputValue: string;
onSend: (value: string) => void;
onSend: (params: SendMessageParams) => void;
disabledChange: () => void;
}
const ChatIcons: React.FC<ChatIconsProps> = ({
lineCount,
isChatMode,
curChatEnd,
inputValue,
@@ -21,50 +24,48 @@ const ChatIcons: React.FC<ChatIconsProps> = ({
disabledChange,
}) => {
const renderSendButton = () => {
if (!isChatMode) return null;
if (!isChatMode) return;
if (curChatEnd) {
return (
<button
className={`ml-1 p-1 ${
inputValue ? "bg-[#0072FF]" : "bg-[#E4E5F0] dark:bg-[rgb(84,84,84)]"
} rounded-full transition-colors h-6`}
className={clsx(
"flex items-center justify-center rounded-full transition-colors min-w-6 h-6 bg-[#E4E5F0] dark:bg-[rgb(84,84,84)]",
{
"!bg-[#0072FF]": inputValue || isAttachmentsUploaded(),
}
)}
type="submit"
onClick={() => onSend(inputValue.trim())}
onClick={() => {
onSend({
message: inputValue.trim(),
attachments: getUploadedAttachmentsId(),
});
}}
>
<Send className="w-4 h-4 text-white" />
<VisibleKey shortcut="enter">
<Send className="size-[14px] text-white" />
</VisibleKey>
</button>
);
}
if (!curChatEnd) {
return (
<button
className={`ml-1 px-1 bg-[#0072FF] rounded-full transition-colors`}
type="submit"
onClick={() => disabledChange()}
>
<StopIcon
size={16}
className="w-4 h-4 text-white"
aria-label="Stop message"
/>
</button>
);
}
return null;
return (
<button
className={`ml-1 px-1 bg-[#0072FF] rounded-full transition-colors`}
type="submit"
onClick={() => disabledChange()}
>
<StopIcon
size={16}
className="w-4 h-4 text-white"
aria-label="Stop message"
/>
</button>
);
};
return (
<>
{lineCount === 1 ? (
renderSendButton()
) : (
<div className="w-full flex justify-end mt-1">{renderSendButton()}</div>
)}
</>
);
return renderSendButton();
};
export default ChatIcons;

View File

@@ -1,58 +0,0 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import VisibleKey from "@/components/Common/VisibleKey";
interface ConnectionErrorProps {
reconnect: () => void;
connected: boolean;
}
export default function ConnectionError({
reconnect,
connected,
}: ConnectionErrorProps) {
const { t } = useTranslation();
const [reconnectCountdown, setReconnectCountdown] = useState<number>(0);
useEffect(() => {
if (!reconnectCountdown || connected) {
setReconnectCountdown(0);
return;
}
if (reconnectCountdown > 0) {
const timer = setTimeout(() => {
setReconnectCountdown(reconnectCountdown - 1);
}, 1000);
return () => clearTimeout(timer);
}
}, [reconnectCountdown, connected]);
return (
<div className="absolute top-0 right-0 bottom-0 left-0 px-2 py-4 bg-[rgba(238,238,238,0.98)] dark:bg-[rgba(32,33,38,0.9)] backdrop-blur-[2px] rounded-md font-normal text-xs text-gray-400 flex items-center gap-4 z-10">
{t("search.input.connectionError")}
<div
className="px-1 h-[24px] text-[#0061FF] font-normal text-xs flex items-center justify-center cursor-pointer underline"
onClick={() => {
reconnect();
setReconnectCountdown(10);
}}
>
{reconnectCountdown > 0 ? (
`${t("search.input.connecting")}(${reconnectCountdown}s)`
) : (
<VisibleKey
shortcut="R"
onKeyPress={() => {
reconnect();
setReconnectCountdown(10);
}}
>
{t("search.input.reconnect")}
</VisibleKey>
)}
</div>
</div>
);
}

View File

@@ -124,7 +124,6 @@ export const DocumentList: React.FC<DocumentListProps> = ({
}
}
console.log("_docs", from, queryStrings, response);
const list = response?.hits ?? [];
const allTotal = response?.total_hits ?? 0;
// set first select hover

View File

@@ -1,5 +1,5 @@
import { useAsyncEffect, useDebounce, useKeyPress, useUnmount } from "ahooks";
import { useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { CircleCheck, FolderDown, Loader } from "lucide-react";
import clsx from "clsx";
import { useTranslation } from "react-i18next";
@@ -60,7 +60,7 @@ export interface SearchExtensionItem {
views: number;
};
checksum: string;
installed: boolean;
installed?: boolean;
commands?: Array<{
type: string;
name: string;
@@ -73,7 +73,7 @@ export interface SearchExtensionItem {
}>;
}
const ExtensionStore = () => {
const ExtensionStore = ({ extensionId }: { extensionId?: string }) => {
const {
searchValue,
selectedExtension,
@@ -107,7 +107,26 @@ const ExtensionStore = () => {
};
}, [selectedExtension]);
const handleExtensionDetail = useCallback(async () => {
try {
const detail = await platformAdapter.invokeBackend<SearchExtensionItem>(
"extension_detail",
{
id: extensionId,
}
);
setSelectedExtension(detail);
setVisibleExtensionDetail(true);
} catch (error) {
addError(String(error));
}
}, [extensionId, installingExtensions]);
useAsyncEffect(async () => {
if (extensionId) {
return handleExtensionDetail();
}
const result = await platformAdapter.invokeBackend<SearchExtensionItem[]>(
"search_extension",
{
@@ -125,7 +144,7 @@ const ExtensionStore = () => {
setList(result ?? []);
setSelectedExtension(result?.[0]);
}, [debouncedSearchValue]);
}, [debouncedSearchValue, extensionId]);
useUnmount(() => {
setSelectedExtension(void 0);

View File

@@ -18,11 +18,13 @@ import { useAssistantManager } from "./AssistantManager";
import InputControls from "./InputControls";
import { useExtensionsStore } from "@/stores/extensionsStore";
import AudioRecording from "../AudioRecording";
import { isDefaultServer } from "@/utils";
import { getUploadedAttachmentsId, isDefaultServer } from "@/utils";
import { useTauriFocus } from "@/hooks/useTauriFocus";
import { SendMessageParams } from "../Assistant/Chat";
import { isEmpty } from "lodash-es";
interface ChatInputProps {
onSend: (message: string) => void;
onSend: (params: SendMessageParams) => void;
disabled: boolean;
disabledChange: () => void;
changeMode?: (isChatMode: boolean) => void;
@@ -84,18 +86,13 @@ export default function ChatInput({
}: ChatInputProps) {
const { t } = useTranslation();
const currentAssistant = useConnectStore((state) => state.currentAssistant);
const setBlurred = useAppStore((state) => state.setBlurred);
const isTauri = useAppStore((state) => state.isTauri);
const { currentAssistant } = useConnectStore();
const { sourceData, goAskAi } = useSearchStore();
const { modifierKey, returnToInput, setModifierKeyPressed } =
useShortcutsStore();
const language = useAppStore((state) => {
return state.language;
});
const { isTauri, language, setBlurred } = useAppStore();
useEffect(() => {
return () => {
@@ -108,6 +105,7 @@ export default function ChatInput({
const { curChatEnd } = useChatStore();
const { setSearchValue, visibleExtensionStore, selectedExtension } =
useSearchStore();
const { uploadAttachments } = useChatStore();
useTauriFocus({
onFocus() {
@@ -122,12 +120,17 @@ export default function ChatInput({
const handleSubmit = useCallback(() => {
const trimmedValue = inputValue.trim();
console.log("handleSubmit", trimmedValue, disabled);
if (trimmedValue && !disabled) {
if ((trimmedValue || !isEmpty(uploadAttachments)) && !disabled) {
changeInput("");
onSend(trimmedValue);
onSend({
message: trimmedValue,
attachments: getUploadedAttachmentsId(),
});
}
}, [inputValue, disabled, onSend]);
}, [inputValue, disabled, onSend, uploadAttachments]);
useKeyboardHandlers();
@@ -138,7 +141,7 @@ export default function ChatInput({
changeInput(value);
setSearchValue(value);
if (!isChatMode) {
onSend(value);
onSend({ message: value });
}
},
[changeInput, isChatMode, onSend]
@@ -289,16 +292,6 @@ export default function ChatInput({
}}
/>
)}
{isChatMode && curChatEnd && (
<div
className={`absolute ${
lineCount === 1 ? "-top-[5px]" : "top-[calc(100%-30px)]"
} right-[12px]`}
>
<VisibleKey shortcut="↩︎" />
</div>
)}
</div>
);

View File

@@ -16,8 +16,7 @@ import { useAppStore } from "@/stores/appStore";
import { useSearchStore } from "@/stores/searchStore";
import { useExtensionsStore } from "@/stores/extensionsStore";
import { parseSearchQuery, SearchQuery } from "@/utils";
// import InputUpload from "./InputUpload";
// import AiSummaryIcon from "@/components/Common/Icons/AiSummaryIcon";
import InputUpload from "./InputUpload";
interface InputControlsProps {
isChatMode: boolean;
@@ -56,16 +55,16 @@ const InputControls = ({
isChatPage,
hasModules,
changeMode,
}: // checkScreenPermission,
// requestScreenPermission,
// getScreenMonitors,
// getScreenWindows,
// captureWindowScreenshot,
// captureMonitorScreenshot,
// openFileDialog,
// getFileMetadata,
// getFileIcon,
InputControlsProps) => {
checkScreenPermission,
requestScreenPermission,
getScreenMonitors,
getScreenWindows,
captureWindowScreenshot,
captureMonitorScreenshot,
openFileDialog,
getFileMetadata,
getFileIcon,
}: InputControlsProps) => {
const { t } = useTranslation();
const isTauri = useAppStore((state) => state.isTauri);
@@ -171,22 +170,24 @@ InputControlsProps) => {
>
{isChatMode ? (
<div className="flex gap-2 text-[12px] leading-3 text-[#333] dark:text-[#d8d8d8]">
{/* <InputUpload
checkScreenPermission={checkScreenPermission}
requestScreenPermission={requestScreenPermission}
getScreenMonitors={getScreenMonitors}
getScreenWindows={getScreenWindows}
captureMonitorScreenshot={captureMonitorScreenshot}
captureWindowScreenshot={captureWindowScreenshot}
openFileDialog={openFileDialog}
getFileMetadata={getFileMetadata}
getFileIcon={getFileIcon}
/> */}
{source?.upload?.enabled && (
<InputUpload
checkScreenPermission={checkScreenPermission}
requestScreenPermission={requestScreenPermission}
getScreenMonitors={getScreenMonitors}
getScreenWindows={getScreenWindows}
captureMonitorScreenshot={captureMonitorScreenshot}
captureWindowScreenshot={captureWindowScreenshot}
openFileDialog={openFileDialog}
getFileMetadata={getFileMetadata}
getFileIcon={getFileIcon}
/>
)}
{source?.type === "deep_think" && source?.config?.visible && (
<button
className={clsx(
"flex items-center gap-1 p-1 rounded-md transition hover:bg-[#EDEDED] dark:hover:bg-[#202126]",
"flex items-center justify-center gap-1 h-[20px] px-1 rounded-md transition hover:bg-[#EDEDED] dark:hover:bg-[#202126]",
{
"!bg-[rgba(0,114,255,0.3)]": isDeepThinkActive,
}
@@ -231,7 +232,8 @@ InputControlsProps) => {
getMCPByServer={getMCPByServer}
/>
{!(source?.datasource?.enabled && source?.datasource?.visible) &&
{!source?.upload?.enabled &&
!(source?.datasource?.enabled && source?.datasource?.visible) &&
(source?.type !== "deep_think" || !source?.config?.visible) &&
!(source?.mcp_servers?.enabled && source?.mcp_servers?.visible) && (
<div className="px-[9px]">

View File

@@ -1,4 +1,4 @@
import { FC, Fragment, MouseEvent } from "react";
import { FC, Fragment, MouseEvent, useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import { ChevronRight, Plus } from "lucide-react";
import {
@@ -12,13 +12,15 @@ import {
} from "@headlessui/react";
import { castArray, find, isNil } from "lodash-es";
import { nanoid } from "nanoid";
import { useCreation, useKeyPress, useMount, useReactive } from "ahooks";
import { useCreation, useMount, useReactive } from "ahooks";
import { useChatStore } from "@/stores/chatStore";
import { useAppStore } from "@/stores/appStore";
import Tooltip from "@/components/Common/Tooltip";
import { useShortcutsStore } from "@/stores/shortcutsStore";
import clsx from "clsx";
import { useConnectStore } from "@/stores/connectStore";
import { filesize } from "@/utils";
import VisibleKey from "../Common/VisibleKey";
interface State {
screenRecordingPermission?: boolean;
@@ -61,9 +63,26 @@ const InputUpload: FC<InputUploadProps> = (props) => {
getFileMetadata,
} = props;
const { t, i18n } = useTranslation();
const { uploadFiles, setUploadFiles } = useChatStore();
const { uploadAttachments, setUploadAttachments } = useChatStore();
const { withVisibility, addError } = useAppStore();
const { modifierKey, addFile, modifierKeyPressed } = useShortcutsStore();
const { addFile } = useShortcutsStore();
const { currentAssistant } = useConnectStore();
const uploadMaxSizeRef = useRef(1024 * 1024);
const uploadMaxCountRef = useRef(6);
const setVisibleStartPage = useConnectStore((state) => {
return state.setVisibleStartPage;
});
useEffect(() => {
if (!currentAssistant?._source?.upload) return;
const { max_file_size_in_bytes, max_file_count } =
currentAssistant._source.upload;
uploadMaxSizeRef.current = max_file_size_in_bytes;
uploadMaxCountRef.current = max_file_count;
}, [currentAssistant]);
const state = useReactive<State>({
screenshotableMonitors: [],
@@ -83,19 +102,25 @@ const InputUpload: FC<InputUploadProps> = (props) => {
if (isNil(selectedFiles)) return;
setVisibleStartPage(false);
handleUploadFiles(selectedFiles);
};
const handleUploadFiles = async (paths: string | string[]) => {
const files: typeof uploadFiles = [];
const files: typeof uploadAttachments = [];
for await (const path of castArray(paths)) {
if (find(uploadFiles, { path })) continue;
if (find(uploadAttachments, { path })) continue;
const stat = await getFileMetadata(path);
if (stat.size / 1024 / 1024 > 100) {
addError(t("search.input.uploadFileHints.maxSize"));
if (stat.size > uploadMaxSizeRef.current) {
addError(
t("search.input.uploadFileHints.maxSize", {
replace: [filesize(uploadMaxSizeRef.current)],
})
);
continue;
}
@@ -107,7 +132,7 @@ const InputUpload: FC<InputUploadProps> = (props) => {
});
}
setUploadFiles([...uploadFiles, ...files]);
setUploadAttachments([...uploadAttachments, ...files]);
};
const menuItems = useCreation<MenuItem[]>(() => {
@@ -172,28 +197,20 @@ const InputUpload: FC<InputUploadProps> = (props) => {
i18n.language,
]);
useKeyPress(`${modifierKey}.${addFile}`, handleSelectFile);
return (
<Menu>
<MenuButton className="flex p-1 rounded-md transition hover:bg-[#EDEDED] dark:hover:bg-[#202126]">
<Tooltip content={t("search.input.uploadFileHints.tooltip")}>
<Plus
className={clsx("size-3 scale-[1.3]", {
hidden: modifierKeyPressed,
})}
/>
<div
className={clsx(
"size-4 flex items-center justify-center font-normal text-xs text-[#333] leading-[14px] bg-[#ccc] dark:bg-[#6B6B6B] rounded-md shadow-[-6px_0px_6px_2px_#fff] dark:shadow-[-6px_0px_6px_2px_#000]",
{
hidden: !modifierKeyPressed,
}
)}
>
{addFile}
</div>
<MenuButton className="flex items-center justify-center h-[20px] px-1 rounded-md transition hover:bg-[#EDEDED] dark:hover:bg-[#202126]">
<Tooltip
content={t("search.input.uploadFileHints.tooltip", {
replace: [
uploadMaxCountRef.current,
filesize(uploadMaxSizeRef.current),
],
})}
>
<VisibleKey shortcut={addFile} onKeyPress={handleSelectFile}>
<Plus className="size-3 scale-[1.3]" />
</VisibleKey>
</Tooltip>
</MenuButton>

View File

@@ -166,7 +166,7 @@ export default function MCPPopover({
return (
<div
className={clsx(
"flex items-center gap-1 p-1 rounded-md transition hover:bg-[#EDEDED] dark:hover:bg-[#202126] cursor-pointer",
"flex justify-center items-center gap-1 h-[20px] px-1 rounded-md transition hover:bg-[#EDEDED] dark:hover:bg-[#202126] cursor-pointer",
{
"!bg-[rgba(0,114,255,0.3)]": isMCPActive,
}

View File

@@ -1,4 +1,4 @@
import { useEffect, memo, useRef } from "react";
import { useEffect, memo, useRef, useCallback, useState } from "react";
import DropdownList from "./DropdownList";
import { SearchResults } from "@/components/Search/SearchResults";
@@ -36,6 +36,8 @@ const SearchResultsPanel = memo<{
performSearch,
} = searchState;
const [extensionId, setExtensionId] = useState<string>();
useEffect(() => {
if (!isChatMode && input) {
performSearch(input);
@@ -58,26 +60,63 @@ const SearchResultsPanel = memo<{
}
}, [selectedSearchContent]);
const handleOpenExtensionStore = useCallback(() => {
platformAdapter.showWindow();
changeMode && changeMode(false);
if (visibleExtensionStore || visibleExtensionDetail) return;
changeInput("");
setSearchValue("");
setVisibleExtensionStore(true);
}, [
changeMode,
visibleExtensionStore,
visibleExtensionDetail,
changeInput,
setSearchValue,
setVisibleExtensionStore,
]);
useEffect(() => {
const unlisten = platformAdapter.listenEvent("open-extension-store", () => {
platformAdapter.showWindow();
changeMode && changeMode(false);
const unlisten = platformAdapter.listenEvent(
"open-extension-store",
handleOpenExtensionStore
);
const unlisten_install = platformAdapter.listenEvent(
"extension_install_success",
(event) => {
const { extensionId } = event.payload;
if (visibleExtensionStore || visibleExtensionDetail) return;
changeInput("");
setSearchValue("");
setVisibleExtensionStore(true);
});
setExtensionId(extensionId);
}
);
return () => {
unlisten.then((fn) => {
fn();
});
unlisten_install.then((fn) => {
fn();
});
};
}, [visibleExtensionStore, visibleExtensionDetail]);
}, [handleOpenExtensionStore]);
if (visibleExtensionStore) return <ExtensionStore />;
useEffect(() => {
if (visibleExtensionDetail) return;
setExtensionId(void 0);
}, [visibleExtensionDetail]);
useEffect(() => {
if (!extensionId) return;
handleOpenExtensionStore();
}, [extensionId]);
if (visibleExtensionStore) {
return <ExtensionStore extensionId={extensionId} />;
}
if (goAskAi) return <AskAi isChatMode={isChatMode} />;
if (suggests.length === 0) return <NoResults />;

View File

@@ -172,7 +172,7 @@ export default function SearchPopover({
return (
<div
className={clsx(
"flex items-center gap-1 p-1 rounded-md transition hover:bg-[#EDEDED] dark:hover:bg-[#202126] cursor-pointer",
"flex justify-center items-center gap-1 h-[20px] px-1 rounded-md transition hover:bg-[#EDEDED] dark:hover:bg-[#202126] cursor-pointer",
{
"!bg-[rgba(0,114,255,0.3)]": isSearchActive,
}

View File

@@ -13,18 +13,21 @@ import { useMount } from "ahooks";
import Search from "@/components/Search/Search";
import InputBox from "@/components/Search/InputBox";
import ChatAI, { ChatAIRef } from "@/components/Assistant/Chat";
import ChatAI, {
ChatAIRef,
SendMessageParams,
} from "@/components/Assistant/Chat";
import { isLinux, isWin } from "@/utils/platform";
import { appReducer, initialAppState } from "@/reducers/appReducer";
import { useWindowEvents } from "@/hooks/useWindowEvents";
import { useAppStore } from "@/stores/appStore";
import { useAuthStore } from "@/stores/authStore";
import platformAdapter from "@/utils/platformAdapter";
import { useStartupStore } from "@/stores/startupStore";
import { useThemeStore } from "@/stores/themeStore";
import { useConnectStore } from "@/stores/connectStore";
import { useAppearanceStore } from "@/stores/appearanceStore";
import type { StartPage } from "@/types/chat";
import { hasUploadingAttachment } from "@/utils";
interface SearchChatProps {
isTauri?: boolean;
@@ -104,10 +107,6 @@ function SearchChat({
useWindowEvents();
const initializeListeners_auth = useAuthStore((state) => {
return state.initializeListeners;
});
const setTheme = useThemeStore((state) => state.setTheme);
const setIsDark = useThemeStore((state) => state.setIsDark);
@@ -124,8 +123,9 @@ function SearchChat({
useEffect(() => {
const init = async () => {
await initializeListeners_auth();
await platformAdapter.commands("get_app_search_source");
if (isTauri) {
await platformAdapter.commands("get_app_search_source");
}
};
init();
@@ -146,10 +146,12 @@ function SearchChat({
}, []);
const handleSendMessage = useCallback(
async (value: string) => {
dispatch({ type: "SET_INPUT", payload: value });
async (params: SendMessageParams) => {
if (hasUploadingAttachment()) return;
dispatch({ type: "SET_INPUT", payload: params?.message ?? "" });
if (isChatMode) {
chatAIRef.current?.init(value);
chatAIRef.current?.init(params);
}
},
[isChatMode]

View File

@@ -1,6 +1,8 @@
import { useTranslation } from "react-i18next";
import { Command, RotateCcw } from "lucide-react";
import { useEffect } from "react";
import { Button } from "@headlessui/react";
import clsx from "clsx";
import { formatKey } from "@/utils/keyboardUtils";
import SettingsItem from "@/components/Settings/SettingsItem";
@@ -9,7 +11,7 @@ import {
INITIAL_MODE_SWITCH,
INITIAL_RETURN_TO_INPUT,
// INITIAL_VOICE_INPUT,
// INITIAL_ADD_FILE,
INITIAL_ADD_FILE,
INITIAL_DEEP_THINKING,
INITIAL_INTERNET_SEARCH,
INITIAL_INTERNET_SEARCH_SCOPE,
@@ -28,8 +30,6 @@ import { ModifierKey } from "@/types/index";
import platformAdapter from "@/utils/platformAdapter";
import { useAppStore } from "@/stores/appStore";
import SettingsInput from "@/components/Settings/SettingsInput";
import { Button } from "@headlessui/react";
import clsx from "clsx";
export const modifierKeys: ModifierKey[] = isMac
? ["meta", "ctrl"]
@@ -46,8 +46,8 @@ const Shortcuts = () => {
setReturnToInput,
// voiceInput,
// setVoiceInput,
// addFile,
// setAddFile,
addFile,
setAddFile,
deepThinking,
setDeepThinking,
internetSearch,
@@ -66,8 +66,8 @@ const Shortcuts = () => {
setNewSession,
fixedWindow,
setFixedWindow,
serviceList,
setServiceList,
serviceListShortcut,
setServiceListShortcut,
external,
setExternal,
aiOverview,
@@ -106,15 +106,13 @@ const Shortcuts = () => {
// value: voiceInput,
// setValue: setVoiceInput,
// },
// {
// title: "settings.advanced.shortcuts.addFile.title",
// description: "settings.advanced.shortcuts.addFile.description",
// value: addFile,
// setValue: setAddFile,
// reset: () => {
// handleChange(INITIAL_ADD_FILE, setAddFile);
// },
// },
{
title: "settings.advanced.shortcuts.addFile.title",
description: "settings.advanced.shortcuts.addFile.description",
initialValue: INITIAL_ADD_FILE,
value: addFile,
setValue: setAddFile,
},
{
title: "settings.advanced.shortcuts.deepThinking.title",
description: "settings.advanced.shortcuts.deepThinking.description",
@@ -183,8 +181,8 @@ const Shortcuts = () => {
title: "settings.advanced.shortcuts.serviceList.title",
description: "settings.advanced.shortcuts.serviceList.description",
initialValue: INITIAL_SERVICE_LIST,
value: serviceList,
setValue: setServiceList,
value: serviceListShortcut,
setValue: setServiceListShortcut,
},
{
title: "settings.advanced.shortcuts.external.title",

View File

@@ -1,56 +0,0 @@
import { useEffect } from "react";
import { Globe } from "lucide-react";
import { useTranslation } from "react-i18next";
import SettingsItem from "./SettingsItem";
import { useAppStore } from "@/stores/appStore";
import { AppEndpoint } from "@/types/index";
const ENDPOINTS = [
{ value: "https://coco.infini.cloud", label: "https://coco.infini.cloud" },
{ value: "http://localhost:9000", label: "http://localhost:9000" },
{ value: "http://infini.tpddns.cn:27200", label: "http://infini.tpddns.cn:27200" },
];
export default function AdvancedSettings() {
const { t } = useTranslation();
const endpoint = useAppStore(state => state.endpoint);
const setEndpoint = useAppStore(state => state.setEndpoint);
useEffect(() => {}, [endpoint]);
const onChangeEndpoint = async (newEndpoint: AppEndpoint) => {
await setEndpoint(newEndpoint);
};
return (
<div className="space-y-8">
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
{t('settings.advanced.title')}
</h2>
<div className="space-y-6">
<SettingsItem
icon={Globe}
title={t('settings.advanced.endpoint.title')}
description={t('settings.advanced.endpoint.description')}
>
<div className={`p-4 rounded-lg`}>
<select
value={endpoint}
onChange={(e) => onChangeEndpoint(e.target.value as AppEndpoint)}
className={`w-full px-3 py-2 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white border-gray-300 text-gray-900 dark:bg-gray-800 dark:border-gray-600 dark:text-white`}
>
{ENDPOINTS.map(({ value, label }) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</div>
</SettingsItem>
</div>
</div>
</div>
);
}

View File

@@ -1,10 +1,11 @@
import { useContext, useMemo, useState } from "react";
import { filesize } from "filesize";
import dayjs from "dayjs";
import { useTranslation } from "react-i18next";
import { useAsyncEffect } from "ahooks";
import platformAdapter from "@/utils/platformAdapter";
import { ExtensionsContext } from "../../../index";
import { filesize } from "@/utils";
interface Metadata {
name: string;
@@ -57,7 +58,7 @@ const App = () => {
},
{
label: t("settings.extensions.application.details.size"),
value: filesize(size, { standard: "jedec", spacer: "" }),
value: filesize(size),
},
{
label: t("settings.extensions.application.details.created"),

View File

@@ -1,137 +1,164 @@
import { FC, useMemo, useState, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { isArray } from "lodash-es";
import { useAsyncEffect, useMount } from "ahooks";
import { AssistantFetcher } from "@/components/Assistant/AssistantFetcher";
import SettingsSelectPro from "@/components/Settings/SettingsSelectPro";
import { useAppStore } from "@/stores/appStore";
import platformAdapter from "@/utils/platformAdapter";
import { useAsyncEffect, useMount } from "ahooks";
import { FC, useMemo, useState } from "react";
import { ExtensionId } from "../../..";
import { useTranslation } from "react-i18next";
import { isArray } from "lodash-es";
import { ExtensionId } from "@/components/Settings/Extensions/index";
import { useConnectStore } from "@/stores/connectStore";
import type { Server } from "@/types/server";
interface Assistant {
id: string;
name?: string;
icon?: string;
description?: string;
}
interface SharedAiProps {
id: ExtensionId;
server?: any;
setServer: (server: any) => void;
assistant?: any;
setAssistant: (assistant: any) => void;
server?: Server;
setServer: (server: Server | undefined) => void;
assistant?: Assistant;
setAssistant: (assistant: Assistant | undefined) => void;
}
const SharedAi: FC<SharedAiProps> = (props) => {
const { id, server, setServer, assistant, setAssistant } = props;
const [serverList, setServerList] = useState<any[]>([server]);
const [assistantList, setAssistantList] = useState<any[]>([assistant]);
const serverList = useConnectStore((state) => state.serverList);
const [list, setList] = useState<Server[]>([]);
const [assistantList, setAssistantList] = useState<Assistant[]>([]);
const addError = useAppStore((state) => state.addError);
const { fetchAssistant } = AssistantFetcher({});
const { t } = useTranslation();
const [assistantSearchValue, setAssistantSearchValue] = useState("");
const [isLoadingAssistants, setIsLoadingAssistants] = useState(false);
const getEnabledServers = useCallback((servers: Server[]): Server[] => {
if (!isArray(servers)) return [];
return servers.filter(
(s) => s.enabled && s.available && (s.public || s.profile)
);
}, []);
useMount(async () => {
try {
const data = await platformAdapter.invokeBackend<any[]>(
"list_coco_servers"
);
const enabledServers = getEnabledServers(serverList);
setList(enabledServers);
if (isArray(data)) {
const enabledServers = data.filter(
(s) => s.enabled && s.available && (s.public || s.profile)
);
setServerList(enabledServers);
if (server) {
const matchServer = enabledServers.find((item) => {
return item.id === server.id;
});
if (matchServer) {
return setServer(matchServer);
}
}
setServer(enabledServers[0]);
if (enabledServers.length === 0) {
setServer(undefined);
return;
}
if (server) {
const matchServer = enabledServers.find((item) => item.id === server.id);
if (matchServer) {
setServer(matchServer);
return;
}
}
setServer(enabledServers[0]);
} catch (error) {
addError(String(error));
console.error('Failed to load servers:', error);
addError(`Failed to load servers: ${String(error)}`);
}
});
useAsyncEffect(async () => {
try {
if (!server) return;
if (!server) {
setAssistantList([]);
setAssistant(undefined);
return;
}
setIsLoadingAssistants(true);
try {
const data = await fetchAssistant({
current: 1,
pageSize: 1000,
pageSize: 100,
serverId: server.id,
query: assistantSearchValue,
});
const list = data.list.map((item: any) => item._source);
const assistants: Assistant[] = data.list.map((item: any) => item._source);
setAssistantList(assistants);
setAssistantList(list);
if (assistants.length === 0) {
setAssistant(undefined);
return;
}
if (assistant) {
const matched = list.find((item: any) => {
return item.id === assistant.id;
});
const matched = assistants.find((item) => item.id === assistant.id);
if (matched) {
return setAssistant(matched);
setAssistant(matched);
return;
}
}
setAssistant(list[0]);
setAssistant(assistants[0]);
} catch (error) {
addError(String(error));
console.error('Failed to fetch assistants:', error);
addError(`Failed to fetch assistants: ${String(error)}`);
setAssistantList([]);
setAssistant(undefined);
} finally {
setIsLoadingAssistants(false);
}
}, [server, assistantSearchValue]);
}, [server?.id, assistantSearchValue]);
const selectList = useMemo(() => {
return [
{
label: t(
"settings.extensions.shardAi.details.linkedAssistant.label.cocoServer"
),
value: server?.id,
icon: server?.provider?.icon,
data: serverList,
searchable: false,
onChange: (value: string) => {
const matched = serverList.find((item) => item.id === value);
setServer(matched);
},
const serverSelectConfig = {
label: t(
"settings.extensions.shardAi.details.linkedAssistant.label.cocoServer"
),
value: server?.id,
icon: server?.provider?.icon,
data: list,
searchable: false,
onChange: (value: string) => {
const matched = list.find((item) => item.id === value);
setServer(matched);
},
{
label: t(
"settings.extensions.shardAi.details.linkedAssistant.label.aiAssistant"
),
value: assistant?.id,
icon: assistant?.icon,
data: assistantList,
searchable: true,
onChange: (value: string) => {
const matched = assistantList.find((item) => item.id === value);
onSearch: undefined,
};
setAssistant(matched);
},
onSearch: (value: string) => {
setAssistantSearchValue(value);
},
const assistantSelectConfig = {
label: t(
"settings.extensions.shardAi.details.linkedAssistant.label.aiAssistant"
),
value: assistant?.id,
icon: assistant?.icon,
data: assistantList,
searchable: true,
onChange: (value: string) => {
const matched = assistantList.find((item) => item.id === value);
setAssistant(matched);
},
];
}, [serverList, assistantList, server, assistant]);
onSearch: (value: string) => {
setAssistantSearchValue(value);
},
};
const renderDescription = () => {
if (id === "QuickAIAccess") {
return t("settings.extensions.quickAiAccess.description");
}
return [serverSelectConfig, assistantSelectConfig];
}, [list, assistantList, server?.id, assistant?.id, t]);
if (id === "AIOverview") {
return t("settings.extensions.aiOverview.description");
const renderDescription = useCallback(() => {
switch (id) {
case "QuickAIAccess":
return t("settings.extensions.quickAiAccess.description");
case "AIOverview":
return t("settings.extensions.aiOverview.description");
default:
return null;
}
};
}, [id, t]);
return (
<>
@@ -154,6 +181,7 @@ const SharedAi: FC<SharedAiProps> = (props) => {
searchable={searchable}
onChange={onChange}
onSearch={onSearch}
placeholder={isLoadingAssistants && searchable ? "Loading..." : undefined}
/>
</div>
);

View File

@@ -8,6 +8,11 @@ import SharedAi from "./SharedAi";
import AiOverview from "./AiOverview";
import Calculator from "./Calculator";
import FileSearch from "./FileSearch";
import { Ellipsis } from "lucide-react";
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react";
import platformAdapter from "@/utils/platformAdapter";
import { useAppStore } from "@/stores/appStore";
import { useTranslation } from "react-i18next";
const Details = () => {
const { rootState } = useContext(ExtensionsContext);
@@ -23,6 +28,10 @@ const Details = () => {
const setQuickAiAccessAssistant = useExtensionsStore((state) => {
return state.setQuickAiAccessAssistant;
});
const addError = useAppStore((state) => {
return state.addError;
});
const { t } = useTranslation();
const renderContent = () => {
if (!rootState.activeExtension) return;
@@ -66,12 +75,62 @@ const Details = () => {
};
return (
<div className="flex-1 h-full overflow-auto">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
{rootState.activeExtension?.name}
</h2>
<div className="flex-1 h-full pr-4 pb-4 overflow-auto">
<div className="flex items-start justify-between gap-4 mb-4">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
{rootState.activeExtension?.name}
</h2>
<div className="pr-4 pb-4 text-sm">{renderContent()}</div>
{rootState.activeExtension?.developer && (
<Menu>
<MenuButton className="h-7">
<Ellipsis className="size-5 text-[#999]" />
</MenuButton>
<MenuItems
anchor="bottom end"
className="p-1 text-sm bg-white dark:bg-[#202126] rounded-lg shadow-xs border border-gray-200 dark:border-gray-700"
>
<MenuItem>
<div
className="px-3 py-2 text-nowrap text-red-500 hover:bg-black/5 hover:dark:bg-white/5 rounded-lg cursor-pointer"
onClick={async () => {
try {
const { id, developer } = rootState.activeExtension!;
await platformAdapter.invokeBackend(
"uninstall_extension",
{
extensionId: id,
developer: developer,
}
);
Object.assign(rootState, {
activeExtension: void 0,
extensions: rootState.extensions.filter((item) => {
return item.id !== id;
}),
});
addError(
t("settings.extensions.hints.uninstallSuccess"),
"info"
);
} catch (error) {
addError(String(error));
}
}}
>
{t("settings.extensions.hints.uninstall")}
</div>
</MenuItem>
</MenuItems>
</Menu>
)}
</div>
<div className="text-sm">{renderContent()}</div>
</div>
);
};

View File

@@ -5,13 +5,14 @@ import type { LiteralUnion } from "type-fest";
import { cloneDeep, sortBy } from "lodash-es";
import clsx from "clsx";
import { Plus } from "lucide-react";
import { Button } from "@headlessui/react";
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react";
import platformAdapter from "@/utils/platformAdapter";
import Content from "./components/Content";
import Details from "./components/Details";
import { useExtensionsStore } from "@/stores/extensionsStore";
import SettingsInput from "../SettingsInput";
import { useAppStore } from "@/stores/appStore";
export type ExtensionId = LiteralUnion<
| "Applications"
@@ -90,6 +91,7 @@ export const Extensions = () => {
const { t } = useTranslation();
const state = useReactive<State>(cloneDeep(INITIAL_STATE));
const { configId, setConfigId } = useExtensionsStore();
const { addError } = useAppStore();
useEffect(() => {
getExtensions();
@@ -106,7 +108,7 @@ export const Extensions = () => {
});
const getExtensions = async () => {
const result = await platformAdapter.invokeBackend<[boolean, Extension[]]>(
const extensions = await platformAdapter.invokeBackend<Extension[]>(
"list_extensions",
{
query: state.searchValue,
@@ -115,8 +117,6 @@ export const Extensions = () => {
}
);
const extensions = result[1];
state.extensions = sortBy(extensions, ["name"]);
if (configId) {
@@ -160,14 +160,71 @@ export const Extensions = () => {
{t("settings.extensions.title")}
</h2>
<Button
className="flex items-center justify-center size-6 border rounded-md dark:border-gray-700 hover:!border-[#0096FB] transition"
onClick={() => {
platformAdapter.emitEvent("open-extension-store");
}}
>
<Plus className="size-4 text-[#0096FB]" />
</Button>
<Menu>
<MenuButton className="flex items-center justify-center size-6 border rounded-md dark:border-gray-700 hover:!border-[#0096FB] transition">
<Plus className="size-4 text-[#0096FB]" />
</MenuButton>
<MenuItems
anchor={{ gap: 4 }}
className="p-1 text-sm bg-white dark:bg-[#202126] rounded-lg shadow-xs border border-gray-200 dark:border-gray-700"
>
<MenuItem>
<div
className="px-3 py-2 hover:bg-black/5 hover:dark:bg-white/5 rounded-lg cursor-pointer"
onClick={() => {
platformAdapter.emitEvent("open-extension-store");
}}
>
{t("settings.extensions.menuItem.extensionStore")}
</div>
</MenuItem>
<MenuItem>
<div
className="px-3 py-2 hover:bg-black/5 hover:dark:bg-white/5 rounded-lg cursor-pointer"
onClick={async () => {
try {
const path = await platformAdapter.openFileDialog({
directory: true,
});
if (!path) return;
await platformAdapter.invokeBackend(
"install_local_extension",
{ path }
);
await getExtensions();
addError(
t("settings.extensions.hints.importSuccess"),
"info"
);
} catch (error) {
const errorMessage = String(error);
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"));
}
}
}}
>
{t("settings.extensions.menuItem.localExtensionImport")}
</div>
</MenuItem>
</MenuItems>
</Menu>
</div>
<div className="flex justify-between gap-6 my-4">

View File

@@ -30,6 +30,7 @@ import {
change_shortcut,
unregister_shortcut,
} from "@/commands";
import platformAdapter from "@/utils/platformAdapter";
export function ThemeOption({
icon: Icon,
@@ -167,8 +168,6 @@ export default function GeneralSettings() {
};
// const clearAllCache = useCallback(() => {
// setAuth(undefined, endpoint);
// setUserInfo({}, endpoint);
// useConnectStore.persist.clearStorage();
@@ -248,8 +247,12 @@ export default function GeneralSettings() {
<div className="flex items-center gap-2">
<select
value={currentLanguage}
onChange={(e) => {
setLanguage(e.currentTarget.value);
onChange={(event) => {
const lang = event.currentTarget.value;
setLanguage(lang);
platformAdapter.invokeBackend("update_app_lang", { lang });
}}
className="px-3 py-1.5 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>

View File

@@ -1,27 +0,0 @@
import { Select } from '@headlessui/react'
interface SettingsSelectProps {
options: string[];
value?: string;
onChange?: (value: string) => void;
}
export default function SettingsSelect({
options,
value,
onChange,
}: SettingsSelectProps) {
return (
<Select
value={value}
onChange={(e) => onChange?.(e.target.value)}
className="rounded-md border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:border-blue-500 focus:ring-blue-500"
>
{options.map((option) => (
<option key={option} value={option.toLowerCase()}>
{option}
</option>
))}
</Select>
);
}

View File

@@ -138,7 +138,7 @@ const UpdateApp = ({ isCheckPage }: UpdateAppProps) => {
return (
<Dialog
open={visible}
open={isCheckPage ? true : visible}
as="div"
className="relative z-10 focus:outline-none"
onClose={noop}

View File

@@ -5,3 +5,11 @@ export const HISTORY_PANEL_ID = "headlessui-popover-panel:history-panel";
export const CONTEXT_MENU_PANEL_ID = "headlessui-popover-panel:context-menu";
export const DEFAULT_COCO_SERVER_ID = "default_coco_server";
export const MAIN_WINDOW_LABEL = "main";
export const SETTINGS_WINDOW_LABEL = "settings";
export const CHECK_WINDOW_LABEL = "check";
export const CHAT_WINDOW_LABEL = "chat";

View File

@@ -1,45 +0,0 @@
import { useEffect } from "react";
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import { LogicalSize } from "@tauri-apps/api/dpi";
// Custom hook to auto-resize window based on content height
const useAutoResizeWindow = () => {
// Function to resize the window to the content's size
const resizeWindowToContent = async () => {
const contentHeight = document.getElementById("main_window")?.scrollHeight || 0;
try {
// Resize the window to fit content size
await getCurrentWebviewWindow()?.setSize(
new LogicalSize(680, contentHeight)
);
console.log("Window resized to content size");
} catch (error) {
console.error("Error resizing window:", error);
}
};
useEffect(() => {
// Initially resize the window
resizeWindowToContent();
// Set up a ResizeObserver to listen for changes in content size
const resizeObserver = new ResizeObserver(() => {
resizeWindowToContent();
});
// Observe the document body for content size changes
resizeObserver.observe(document.body);
// Clean up the observer when the component is unmounted
return () => {
resizeObserver.disconnect();
};
}, []); // Only run once when the component is mounted
// Optionally, you can return values if you need to handle window size elsewhere
};
export default useAutoResizeWindow;

View File

@@ -9,6 +9,9 @@ import { useSearchStore } from "@/stores/searchStore";
import { useAuthStore } from "@/stores/authStore";
import { unrequitable } from "@/utils";
import { streamPost } from "@/api/streamFetch";
import { SendMessageParams } from "@/components/Assistant/Chat";
import { isEmpty } from "lodash-es";
import { useChatStore } from "@/stores/chatStore";
export function useChatActions(
setActiveChat: (chat: Chat | undefined) => void,
@@ -40,6 +43,9 @@ export function useChatActions(
} = useConnectStore();
const sourceDataIds = useSearchStore((state) => state.sourceDataIds);
const MCPIds = useSearchStore((state) => state.MCPIds);
const setUploadAttachments = useChatStore((state) => {
return state.setUploadAttachments;
});
const [keyword, setKeyword] = useState("");
@@ -76,7 +82,6 @@ export function useChatActions(
const [_error, res] = await Post(`/chat/${activeChat?._id}/_close`, {});
response = res;
}
console.log("_close", response);
},
[currentService?.id, isTauri]
);
@@ -124,7 +129,6 @@ export function useChatActions(
);
response = res;
}
console.log("_cancel", response);
},
[currentService?.id, isTauri]
);
@@ -170,7 +174,6 @@ export function useChatActions(
...chat,
messages: hits,
};
console.log("id_history", updatedChat);
updatedChatRef.current = updatedChat;
setActiveChat(updatedChat);
callback && callback(updatedChat);
@@ -254,20 +257,6 @@ export function useChatActions(
`chat-stream-${clientId}-${timestamp}`,
(event) => {
const msg = event.payload as string;
try {
// console.log("msg:", JSON.parse(msg));
// console.log("user:", msg.includes(`"user"`));
// console.log("_source:", msg.includes("_source"));
// console.log("result:", msg.includes("result"));
// console.log("");
// console.log("");
// console.log("");
// console.log("");
// console.log("");
} catch (error) {
console.error("Failed to parse JSON in listener:", error);
}
handleChatCreateStreamMessage(msg);
}
);
@@ -289,10 +278,12 @@ export function useChatActions(
);
const prepareChatSession = useCallback(
async (value: string, timestamp: number) => {
async (timestamp: number, value: string) => {
// 1. Cleaning and preparation
await clearAllChunkData();
setUploadAttachments([]);
// 2. Update the status again
await new Promise<void>((resolve) => {
changeInput && changeInput("");
@@ -310,12 +301,17 @@ export function useChatActions(
);
const createNewChat = useCallback(
async (value: string = "") => {
if (!value) return;
async (params?: SendMessageParams) => {
const { message, attachments } = params || {};
console.log("message", message);
console.log("attachments", attachments);
if (!message && isEmpty(attachments)) return;
const timestamp = Date.now();
await prepareChatSession(value, timestamp);
await prepareChatSession(timestamp, message ?? "");
const queryParams = {
search: isSearchActive,
@@ -328,19 +324,22 @@ export function useChatActions(
if (isTauri) {
if (!currentService?.id) return;
console.log("chat_create", clientId, timestamp);
await platformAdapter.commands("chat_create", {
serverId: currentService?.id,
message: value,
message,
attachments,
queryParams,
clientId: `chat-stream-${clientId}-${timestamp}`,
});
console.log("_create end", value);
console.log("_create end", message);
resetChatState();
} else {
await streamPost({
url: "/chat/_create",
body: { message: value },
body: { message },
queryParams,
onMessage: (line) => {
console.log("⏳", line);
@@ -365,12 +364,16 @@ export function useChatActions(
);
const sendMessage = useCallback(
async (content: string, newChat: Chat) => {
if (!newChat?._id || !content) return;
async (newChat: Chat, params?: SendMessageParams) => {
if (!newChat?._id || !params) return;
const { message, attachments } = params;
if (!message && isEmpty(attachments)) return;
const timestamp = Date.now();
await prepareChatSession(content, timestamp);
await prepareChatSession(timestamp, message ?? "");
const queryParams = {
search: isSearchActive,
@@ -388,15 +391,16 @@ export function useChatActions(
serverId: currentService?.id,
sessionId: newChat?._id,
queryParams,
message: content,
message,
attachments,
clientId: `chat-stream-${clientId}-${timestamp}`,
});
console.log("chat_chat end", content, clientId);
console.log("chat_chat end", message, clientId);
resetChatState();
} else {
await streamPost({
url: `/chat/${newChat?._id}/_chat`,
body: { message: content },
body: { message },
queryParams,
onMessage: (line) => {
console.log("line", line);
@@ -421,10 +425,14 @@ export function useChatActions(
);
const handleSendMessage = useCallback(
async (content: string, activeChat?: Chat) => {
if (!activeChat?._id || !content) return;
async (activeChat?: Chat, params?: SendMessageParams) => {
if (!activeChat?._id) return;
await chatHistory(activeChat, (chat) => sendMessage(content, chat));
const { message, attachments } = params ?? {};
if (!message && isEmpty(attachments)) return;
await chatHistory(activeChat, (chat) => sendMessage(chat, params));
},
[chatHistory, sendMessage]
);
@@ -455,7 +463,6 @@ export function useChatActions(
response = res;
}
console.log("_open", response);
return response;
},
[currentService?.id, isTauri]
@@ -484,7 +491,6 @@ export function useChatActions(
response = res;
}
console.log("_history", response);
const hits = response?.hits?.hits || [];
setChats(hits);
}, [
@@ -518,7 +524,7 @@ export function useChatActions(
skipTaskbar: false,
decorations: true,
closable: true,
url: "/ui/chat",
url: "index.html/#/ui/chat",
});
}
},

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