557 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
ayangweb
4709f8c660 feat: enhance ui for skipped version (#834) 2025-07-28 11:43:10 +08:00
SteveLauC
4696aa1759 test: test extract_build_number() (#835)
This commit adds a test for extract_build_number(), which I forgot to do
in commit 067fb7144f6[1].

[1]: 067fb7144f
2025-07-28 11:42:50 +08:00
ayangweb
924fc09516 fix: fix issue with update check failure (#833)
* fix: fix issue with update check failure

* docs: update changelog
2025-07-28 10:06:07 +08:00
SteveLauC
5a700662dd chore: release notes for 0.7.1 (#832) 2025-07-28 10:00:12 +08:00
BiggerRain
8f992bfa92 chore: bump version number to 0.7.1 (#830) 2025-07-27 17:26:08 +08:00
BiggerRain
e7dd27c744 chore: add toggle_move_to_active_space_attribute (#829)
* chore: add toggle_move_to_active_space_attribute

* chore: pin

* chore: add

* update

---------

Co-authored-by: Steve Lau <stevelauc@outlook.com>
2025-07-27 16:50:11 +08:00
ayangweb
7914836c3e fix: correct enter key behavior (#828) 2025-07-27 11:52:40 +08:00
BiggerRain
b37bf1f7c7 chore: bump version number to 0.7.0 (#827) 2025-07-25 19:54:33 +08:00
BiggerRain
419d9d55c5 chore: web componet remove server name (#826) 2025-07-25 18:16:07 +08:00
BiggerRain
d3ed54c771 chore: web component add notification component (#825)
* chroe: web component add notification component

* docs: update notes
2025-07-25 18:15:49 +08:00
ayangweb
8f26dbcbe6 refactor: optimize subpage shortcut context menu (#822)
* refactor: optimize subpage shortcut context menu

* update

* update
2025-07-25 16:43:41 +08:00
ayangweb
663873ae14 refactor: optimize carriage return copying (#823) 2025-07-25 16:43:05 +08:00
SteveLauC
286b1be212 fix: panic on Ubuntu (GNOME) when opening apps (#821)
On Ubuntu (the GNOME version), Coco would panic when users open an app due
to the reason that Coco thinks it is running in an unsupported desktop
environment (DE).

We rely on the environment variable XDG_CURRENT_DESKTOP to detect the DE,
Ubuntu sets this variable to "ubuntu:GNOME" instead of just "GNOME",
which was not handled by the previous implementation.

This commit supports this case. Also, when Coco runs in an unsupported DE,
opening apps should not panic the app. After this commit, we would return
an error.
2025-07-25 15:32:48 +08:00
ayangweb
37221782b0 refactor: optimize shortcut key triggering (#820) 2025-07-25 14:54:32 +08:00
ayangweb
644e291105 fix: fix update window config sync (#818)
* fix: fix update window config sync

* docs: update changelog
2025-07-25 14:47:20 +08:00
BiggerRain
aae6984aa7 fix: re-search data initialization (#817) 2025-07-25 14:43:27 +08:00
ayangweb
dbd296d399 fix: fix enter key on subpages (#819)
* fix: fix enter key on subpages

* docs: update changelog
2025-07-25 14:43:16 +08:00
ayangweb
e2ad25967d fix: fix ctrl+k not working (#815) 2025-07-25 14:30:03 +08:00
ayangweb
21b61d80d8 refactor: optimize method calls for checking for updates (#814) 2025-07-25 13:42:12 +08:00
ayangweb
9f4c693ac4 refactor: optimize line breaks in input boxes (#813) 2025-07-25 12:36:07 +08:00
BiggerRain
45c27cac56 chore: cancel interface param (#816) 2025-07-25 12:16:23 +08:00
BiggerRain
e46035afd4 fix:the client id is the same (#812)
* chore: add

* fix: client id
2025-07-25 11:25:22 +08:00
BiggerRain
1004bb73f4 chore: delay the chat monitoring event (#811) 2025-07-24 20:03:30 +08:00
BiggerRain
d664fa7271 chore: handle reply to message (#799)
* chore: add reply to message

* chore: handle rust data

* log

* chore: id

* feat: add

* chore: loading step

* chore: cur id

* feat: add

* accept query parameters

* chore: add message id for cancel

* chore: remove log

* chore: remove log

---------

Co-authored-by: Steve Lau <stevelauc@outlook.com>
2025-07-24 18:06:59 +08:00
SteveLauC
067fb7144f refactor: use custom version comparator to determine if we should update (#810) 2025-07-24 16:05:36 +08:00
ayangweb
579f91f3aa refactor: refactor version update check (#809) 2025-07-24 11:56:57 +08:00
ayangweb
abe2aecedf fix: fix multiline input issue (#808) 2025-07-24 10:58:57 +08:00
SteveLauC
e8f9a4e627 chore: log querysources to search only when querysource is not set (#807) 2025-07-24 09:39:29 +08:00
ayangweb
22b1558e8b refactor: optimized data fetching for secondary pages (#803) 2025-07-23 18:56:56 +08:00
SteveLauC
ca3b514a65 fix: panic caused by "state() called before manage()" (#806)
This commit fixes the following panic:

```
Time: [2025-07-23-17-03-23]
Location: [/Users/steve/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tauri-2.5.1/src/lib.rs:742:7]
Message: [state() called before manage() for tauri_plugin_global_shortcut::GlobalShortcut<tauri_runtime_wry::Wry<tauri::EventLoopMessage>>]
```

The root cause is that, in a Tauri application, before you can access a piece of
managed state with the .state() method, you must first register it with Tauri
using .manage(). When a user reigsters hotkey for an extension,
initializing extensions will invoke the .state() method, at that point,
.manage() hasn't been called.

The fix is simple, we simply call .manage() earlies (invoked by our
`shortcut::enable_shortcut(app)` function).
2025-07-23 18:56:16 +08:00
SteveLauC
c694c4eda9 chore: display backtrace in panic log (#805)
Having backtrace in the panic log will help debugging a lot. Under
release builds, we strip our binary so the symbols information is
unavailable, but this information is still useful in debug builds.

Panic log in release builds:

```
Time: [YYYY-MM-DD-HH-MM-SS]
Location: [/Users/foo/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tauri-2.5.1/src/lib.rs:742:7]
Message: [state() called before manage() for tauri_plugin_global_shortcut::GlobalShortcut<tauri_runtime_wry::Wry<tauri::EventLoopMessage>>]
Backtrace:
   0: __mh_execute_header
   1: __mh_execute_header
   2: __mh_execute_header
   3: __mh_execute_header
   4: __mh_execute_header
   5: __mh_execute_header
   6: __mh_execute_header
   7: __mh_execute_header
   8: __mh_execute_header
   9: __mh_execute_header
  10: __mh_execute_header
  11: __mh_execute_header
  12: __mh_execute_header
  13: __mh_execute_header
  14: __mh_execute_header
  15: __mh_execute_header
  16: __mh_execute_header
  17: <unknown>
  18: <unknown>
```
2025-07-23 17:00:48 +08:00
ayangweb
ac835c76aa fix: fix shortcut issue in windows context menu (#804)
* fix: fix shortcut issue in windows context menu

* docs: update changelog
2025-07-23 16:20:46 +08:00
SteveLauC
25bbab7432 refactor: clean up unsupported characters from query string in Win Search (#802)
We found that Windows Search would error out if it encounters a single
quote character, the natural solution would be to escape it. But I couldn't
find out how. The approach mentioned by most posts:

```
~="<Unsupported Char>"
```

won't work in my test. So I decided to replace it with a whitespace.

Single quote is not the first unsupported character I know, the newline
character is not supported as well, so it will be handled in the same
way.
2025-07-23 16:13:15 +08:00
ayangweb
cca00e944e fix: fix selection issue after renaming (#800) 2025-07-23 13:59:33 +08:00
SteveLauC
e78fe4ac89 fix: broken windows search (#801)
This commit fixes the search issue introduced by [commit](5c0a865822). We have no idea why the tauri command `get_app_search_source` won't be invoked after that commit on Windows.

This commit resolves the issue by moving the extension init logic to the Rust side.

Also, update the querysource logs in `quey_coco_fusion()`, the old one won't say anything if the querysource list is empty, the new one will tell us that.
2025-07-23 12:33:18 +08:00
Medcl
60fd79f1fa fix: increase read_timeout for HTTP streaming stability (#798) 2025-07-22 18:44:27 +08:00
BiggerRain
5c0a865822 chore: not request the interface if not logged in (#795)
* chore: not request the interface if not logged in

* chore: res

* chore: res

* chore: common interface

* chore: no login

* chore: login

* chore: login

* chore: add

* dbg print servers

* chore: id

* docs: update notes

---------

Co-authored-by: Steve Lau <stevelauc@outlook.com>
2025-07-22 16:15:58 +08:00
SteveLauC
5b50e4b51b ci: add Rust code format check to CI (#797)
This commit adds the Rust code format check to our CI.
2025-07-22 15:11:13 +08:00
SteveLauC
b97386a827 refactor: avoid GLOBAL_TAURI_APP_HANLE if possible (#796)
This commit fixes the Windows panic issue. 

Coco panicked because it accessed `GLOBAL_TAURI_APP_HANDLE` when this global variable wasn't initialized. I removed all the uses of this variable except for the one use in `src-tauri/src/server/http_client.rs`, which I don't have a good way to refactor.

If you are wondering why this didn't happen in the past, the access was triggered by the frontend code, something there likely changed. Regardless, this global variable is still dangerous and error-prone, so we should avoid it.

Also, this commit fixes the issue that the panic hook does not work on Windows because the log filename contains ":", which is not allowed by the Windows file system.
2025-07-22 14:43:27 +08:00
SteveLauC
29aa26af94 chore: add a panic hook to catch panic msg (#793) 2025-07-22 10:34:27 +08:00
BiggerRain
3650d9914c fix: enter key problem (#794)
* fixed: enter key problem

* docs: update notes

* fix: enter key problem
2025-07-22 10:13:08 +08:00
SteveLauC
f26031047c fix: refreshing Coco server should register it to SearchSource (#792) 2025-07-22 08:51:57 +08:00
BiggerRain
c8719926be chore: add 401 unauthorized (#791) 2025-07-21 22:21:07 +08:00
BiggerRain
f1dfc5c730 fixed: chat message confusion (#782)
* fix: chat

* fix: chat

* chore: add session id

* fix: fixed incorrect taskbar icon display on linux (#783)

* fix: fixed incorrect taskbar icon display on linux

* docs: update changelog

* fix: fix data inconsistency issue on secondary pages (#784)

* chore: chat

* chore: chat

* chore: add logging message

* chore: chat

* chore: chat

* chore: add

* feat: add

* chore: chat end

* style: message width

---------

Co-authored-by: ayangweb <75017711+ayangweb@users.noreply.github.com>
Co-authored-by: medcl <m@medcl.net>
2025-07-21 21:17:20 +08:00
SteveLauC
74ed642a42 refactor: tighten up Coco servers state management (#790)
* refactor: tighten up Coco servers state management

* ignore unused warnings

* log out if the failed request has status 401
2025-07-21 20:39:16 +08:00
ayangweb
5a17173620 fix: incorrect status when installing extension (#789)
* fix: incorrect status when installing extension

* docs: update changelog
2025-07-21 18:17:30 +08:00
SteveLauC
29d14ff931 chore: remove unused type ServerTokenResponse (#788)
After this commit[1], type `ServerTokenResponse` became unused, remove
it as well.

[1]: 57ab08fb6d
2025-07-21 15:30:26 +08:00
ayangweb
ad01504766 refactor: decouple window switch services to ensure they operate independently (#786) 2025-07-20 17:26:15 +08:00
SteveLauC
57ab08fb6d chore: remove unused tauri cmd get_server_token (#787)
Found this tauri command while reading the code, then I realized that
token management logic should all be kept in the backend, there is no
need to expose it to the frontend. And indeed, searching for it in the
frontend code showed that it is not used at all.

```sh
$ cd src

$ rg get_server_token
commands/servers.ts
75:export function get_server_token(id: string): Promise<ServerTokenResponse> {
76:  return invokeWithErrorHandler(`get_server_token`, { id });
```

So remove it.
2025-07-20 17:25:32 +08:00
ayangweb
db5c09f80c fix: fix data inconsistency issue on secondary pages (#784) 2025-07-20 10:54:51 +08:00
ayangweb
b1e2c6961d fix: fixed incorrect taskbar icon display on linux (#783)
* fix: fixed incorrect taskbar icon display on linux

* docs: update changelog
2025-07-20 10:08:11 +08:00
BiggerRain
3f4abe51e5 fix: web component server list error (#781)
* chore: update app

* fix: web component server list error

* feat: add

* chore: remove defalut version
2025-07-19 17:07:11 +08:00
ayangweb
060c09e11c fix: resolved minor issues with voice playback (#780)
* fix: resolved minor issues with voice playback

* docs: update changelog

* update
2025-07-19 14:25:19 +08:00
ayangweb
657df482bf fix: correct incorrect assistant display when quick ai access (#779)
* fix: correct incorrect assistant display when quick ai access

* docs: update changelog
2025-07-19 13:54:39 +08:00
ayangweb
f4f7732927 refactor: show specific values in shortcut key conflict tips (#778)
* refactor: show specific values in shortcut key conflict tips

* update

* update

* update

* update

* update

* update

* update
2025-07-19 11:05:17 +08:00
ayangweb
5e536e1444 refactor: separate user agreement and privacy policy links (#777) 2025-07-19 10:24:29 +08:00
ayangweb
2b48cdf84a refactor: add border-radius to extended categories (#776) 2025-07-19 10:08:04 +08:00
BiggerRain
bc37616506 chore: search-chat add language and formatUrl parameters (#775)
* chore: add language

* build: build web

* docs: update notes
2025-07-19 09:34:38 +08:00
ayangweb
07bcd80776 refactor: invoke language update logic earlier (#774) 2025-07-18 16:44:43 +08:00
SteveLauC
7b8b396368 fix: indexing apps does not respect search scope config (#773)
This commit fixes the issue that indexing applications does not
respect the search scope configuration, it always uses the default
values.
2025-07-18 16:26:34 +08:00
ayangweb
823a95d601 fix: restore missing category titles on subpages (#772) 2025-07-18 16:25:44 +08:00
ayangweb
af0b98a41b refactor: rebuild app index with improved suggestions (#771) 2025-07-18 16:15:28 +08:00
SteveLauC
7d0e7cd7dc fix: unregister ext hotkey when it gets deleted (#770)
This commit fixes the bug that when an extension gets uninstalled, its
registered hotkey won't be cleared.
2025-07-18 13:20:41 +08:00
ayangweb
e56d6b1b60 refactor: close the file upload port (#769) 2025-07-18 10:45:05 +08:00
BiggerRain
941cf96a07 style: splash adapts to the width of mobile phones (#768)
* style: splash width style

* docs: update notes
2025-07-17 15:33:24 +08:00
SteveLauC
14fbf2ac5d refactor: do status code check before deserializing response (#767)
* refactor: do status code check before deserializing response

This commit adds a status code check to the following requests, only when
this check passes, we deserialize the response JSON body:

- get_connectors_by_server
- mcp_server_search
- datasource_search

A helper function `status_code_check(response, allowed_status_codes)`
is added to make refactoring easier.

* chore: release notes
2025-07-17 15:08:14 +08:00
SteveLauC
494e2f0d8a chore: Coco app http request headers (#744)
Add the following HTTP headers when making HTTP requests:

- X-OS-NAME
- X-OS-VER
- X-OS-ARCH
- X-APP-NAME
- X-APP-VER
- X-APP-LANG
2025-07-17 11:31:19 +08:00
BiggerRain
e3a3849fa4 chore: search-chat components add formatUrl & think data & icons url (#765)
* chore: web components add formatUrl & think data

* chore: add headers

* chore: add

* chhore: add server url

* docs: update notes

* chore: url

* docs: search chat docs
2025-07-17 09:22:23 +08:00
SteveLauC
0b5e31a476 chore(deps): bump the windows crate (#766)
This commit bumps the windows crate from "0.60.0" to "0.61.3", it should
solve the CI issue happened here[1]:

```text
error[E0277]: `DBOBJECT` doesn't implement `Debug`
     --> C:\Users\runneradmin\.cargo\registry\src\index.crates.io-1949cf8c6b5b557f\windows-0.60.0\src\Windows\Win32\System\Search\mod.rs:21828:5
      |
21826 | #[derive(Clone, Debug, PartialEq)]
      |                 ----- in this derive macro expansion
21827 | pub struct SSVARIANT_0_4 {
21828 |     pub dbobj: DBOBJECT,
      |     ^^^^^^^^^^^^^^^^^^^ the trait `Debug` is not implemented for `DBOBJECT`
      |
      = note: add `#[derive(Debug)]` to `DBOBJECT` or manually `impl Debug for DBOBJECT`
```

[1]: https://github.com/infinilabs/ci/actions/runs/16314479643/job/46076989290
2025-07-16 17:10:32 +08:00
SteveLauC
c8a723ed9d feat: file search for Windows (#762)
This commit implements the file search extension for Windows platforms using the [Windows Search](https://learn.microsoft.com/en-us/windows/win32/search/-search-3x-wds-qryidx-overview) functionality.

Something to note:

1. Searching by file content is not natively supported. Coco would search for all the columns (attributes/fields within the index) with this option:

```rust
        SearchBy::NameAndContents => {
            // Windows File Search does not support searching by file content.
            //
            // `CONTAINS('query_string')` would search all columns for `query_string`,
            // this is the closest solution we have.
            format!("((System.FileName LIKE '%{query_string}%') OR CONTAINS('{query_string}'))")
        }
```

2. Tests have been added, but they failed in our CI for unknown reasons so I disabled them:

```rust
// Skip these tests in our CI, they fail with the following error 
// "SQL is invalid: "0x80041820""
// 
// I have no idea about the underlying root cause
#[cfg(all(test, not(ci)))]
mod test {
```

3. The Windows Search index is not real-time and can return obsolete results. Opening the returned documents could fail if the chosen file has been deleted or moved.
2025-07-16 09:11:53 +08:00
ayangweb
aaf4bf2737 refactor: update the font icon link (#763) 2025-07-15 09:10:26 +08:00
BiggerRain
24b0123a61 docs: add deep wiki docs (#761) 2025-07-11 17:22:18 +08:00
ayangweb
e8bd970cdb refactor: updated the upload endpoint for attachments (#759) 2025-07-10 18:20:32 +08:00
ayangweb
dd3be3a819 refactor: refactored file icon retrieval logic (#757)
* refactor: refactored file icon retrieval logic

* update

* update

* update
2025-07-10 18:10:39 +08:00
Medcl
5b034c28ac chore: make optional fields optional (#758)
* chore: make optional fields optional

* chore: update docs
2025-07-10 18:06:05 +08:00
ayangweb
b17949fe29 refactor: enabling the upload file component (#755)
* refactor: enabling the upload file component

* update
2025-07-10 17:26:44 +08:00
SteveLauC
5d37420109 feat: tauri command get_file_icon() (#756) 2025-07-10 16:51:34 +08:00
ayangweb
1d3ceb0c70 refactor: remove speech-to-text shortcuts (#754) 2025-07-10 13:58:37 +08:00
BiggerRain
4d11afe18e chore: assistant params & styles (#753)
* chore: add

* chore: add

* chore: assistant params & styles

* docs: update notes
2025-07-10 11:47:10 +08:00
SteveLauC
0c0291c8c0 chore: rename QuickLink/quick_link to Quicklink/quicklink (#752)
* chore: rename QuickLink/quick_link to Quicklink/quicklink

Standardize varaible naming to match the correct term: "Quicklink" and "quicklink".
This updates all incorrect variants such as "QuickLink" and "quick_link".

* chore: release notes
2025-07-10 10:18:57 +08:00
ayangweb
cca672b2cb feat: text to speech now powered by LLM (#750)
* feat: support text to speech

* chore: receive bytes stream

* chore: update testing code

* feat: mp3 paly

* update

* docs: update changelog

* update

* update

* update

---------

Co-authored-by: medcl <m@medcl.net>
Co-authored-by: rain9 <15911122312@163.com>
2025-07-10 10:16:51 +08:00
BiggerRain
5b27488402 refactor: adjusted assistant, datasource, mcp_server interface parameters (#746)
* chore: handle mcp interface parameters

* docs: update notes

* chore: remove code

* chore: assistant params

* fix: assistant params

* docs: update notes
2025-07-10 09:48:42 +08:00
SteveLauC
c1c4e0db7b chore: bump dep applications-rs (#751)
* chore: bump dep applications-rs

Currently Coco depends on atty v0.2.14, a crate that has
[vulnerability](https://github.com/infinilabs/coco-app/security/dependabot/25),
here is the dependency chain:

```
coco -> applications-rs -> freedesktop-file-parser 0.1.0 -> atty 0.2.14
```

I bumped the [`freedesktop-file-parser`](7bdb070e45)
crate in our applications-rs crate, which would eliminate the `atty` crate
from the chain to fix the vulnerability.

This commit bumps the applications-rs crate to include the above change.

* chore: release notes
2025-07-09 18:52:17 +08:00
ayangweb
074a7c8b0a fix: prevent window from hiding when moved on Windows (#748)
* fix: prevent window from hiding when moved on Windows

* docs: update changelog

* update
2025-07-09 16:30:41 +08:00
SteveLauC
bc524e19db refactor: adjust extension code hierarchy (#747)
* refactor: adjust extension code hierarchy

In this commit, I refactored the extension code structure.

* We can only install third-party extensions so the `store.rs` file should
  belong to the `third_party` directory.

* Move tauri command `uninstall_extension()` to `extension/mod.rs` from
  `third_party.rs` since one can uninstall an extension regardless of
  how you installed it.

* Refactor the `install_extension_from_store()` function, add more
  descriptive code comments.

Also, a trivial change, bump Rust toolchain and edition to use the
[let-chains](https://blog.rust-lang.org/2025/06/26/Rust-1.88.0/#let-chains) syntax.

* chore: release notes
2025-07-09 16:28:59 +08:00
SteveLauC
05f70d26d9 chore: replace meval-rs with our fork to clear dep warning (#745)
* chore: replace meval-rs with our fork to clear dep warning

This commit replaces the meval-rs dependency with our
[fork](https://github.com/infinilabs/meval-rs). The original meval-rs
crate has not been maintained for a long time and uses nom 1.0, a crate
that was released 9 years ago, which would be rejected by future Rust
compiler because it contains outdated Rust syntaxes. This is the reason
why we are seeing the following warning:

```
warning: the following packages contain code that will be rejected by a future version of Rust: nom v1.2.4
note: to see what the problems were, use the option `--future-incompat-report`, or run `cargo report future-incompatibilities --id 1
```

Switching to our fork would solve this warning.

* chore: release notes
2025-07-08 15:39:58 +08:00
SteveLauC
ab26dc7c6a fix(file search): searching by name&content does not search file name (#743)
* fix(file search): searching by name&content does not search file name

* release note
2025-07-08 09:21:43 +08:00
BiggerRain
6ff6b46139 refactor: create chat & send chat api (#739)
* chore: code format

* fix: build error

* refactor: chat create & chat

* chore: aa

* chore: aa

* refactor: send chat messages

* chore: chat

* chore: web

* chore: add

* docs: update notes
2025-07-07 19:41:29 +08:00
SteveLauC
119fd87a25 fix(file search): apply filters before from/size parameters (#741) 2025-07-07 19:40:46 +08:00
SteveLauC
de226a8fa4 ci: compile-check rust code & run rust tests when Rust code changes (#742)
Run some basic Rust checks in our CI iff rust code changes
2025-07-07 18:14:25 +08:00
SteveLauC
6865957725 chore: icon support for more file types (#740)
This PR adds icon support for more types of files, see the code for the full file type list.

Co-authored-by: ayang <473033518@qq.com>
2025-07-02 16:27:44 +08:00
SteveLauC
87818d69ed refactor: change File Search ext type to extension (#738)
* refactor: change File Search ext type to extension

* chore: release notes
2025-07-02 10:45:54 +08:00
SteveLauC
38b67d01b8 refactor: prioritize stat(2) when checking if a file is dir (#737)
* refactor: prioritize stat(2) when checking if a file is dir

* chore: release notes
2025-07-02 10:00:33 +08:00
ayangweb
a4f4a24730 feat: voice input support in both search and chat modes (#732)
* feat: voice input support in both search and chat modes

* docs: update changelog

* update

* update

* update

* update
2025-07-02 09:35:16 +08:00
BiggerRain
87bd3d020f fix: build error (#736) 2025-07-02 07:03:09 +08:00
SteveLauC
825ac5d565 feat: file search using spotlight (#705)
Co-authored-by: ayang <473033518@qq.com>
2025-07-01 19:19:16 +08:00
BiggerRain
f21a35e15d fix: update information storage cache and styles (#735) 2025-07-01 15:46:37 +08:00
BiggerRain
6e90b28204 style: extension iocn styles (#734) 2025-07-01 13:44:44 +08:00
Hardy
e92e5e5158 chore: typo step name and env (#731)
Co-authored-by: hardy <luohf@infinilabs.com>
2025-06-30 14:40:26 +08:00
Hardy
2ac81566c6 Fix run shell (#730)
* fix: windows platform run with shell

* chore: add rust target

* fix: fix app version and release body

* chore: update step id

---------

Co-authored-by: hardy <luohf@infinilabs.com>
2025-06-30 14:29:52 +08:00
Hardy
b004670dec fix: windows platform run with shell (#729)
* fix: windows platform run with shell

* chore: add rust target

---------

Co-authored-by: hardy <luohf@infinilabs.com>
2025-06-30 14:11:51 +08:00
Hardy
a426e33e6b fix: feature dependcy local path (#728)
* fix: feature dependcy local path

* chore: use build args from env

* chore: remove no use step

---------

Co-authored-by: hardy <luohf@infinilabs.com>
2025-06-30 13:16:54 +08:00
Hardy
bb7dd6bf7c fix: build error on windows platform with cargo add git repo (#727)
Co-authored-by: hardy <luohf@infinilabs.com>
2025-06-30 12:13:46 +08:00
BiggerRain
37c5f2de24 fix: tray not on display (#726) 2025-06-30 10:53:40 +08:00
SteveLauC
ab6c25fe96 chore: release notes for 0.6.0 (#725) 2025-06-29 17:42:32 +08:00
BiggerRain
1fb464df09 fix: open extension store display (#724) 2025-06-29 17:38:14 +08:00
SteveLauC
65aa75043f chore: bump version number to 0.6.0 (#723) 2025-06-29 17:08:06 +08:00
BiggerRain
79dcc7b4ec fix: text display error (#722)
* fix: text  display error

* fix: text  display error

* fix: select extension display install

* fix: select extension display install
2025-06-29 16:47:02 +08:00
BiggerRain
3d29cfe235 chore: rebuild index position (#721) 2025-06-29 15:53:43 +08:00
BiggerRain
aea3a7ba98 chore: rebuild index (#720)
* chore: rebuild index

* chore: rebuild index
2025-06-29 15:39:01 +08:00
BiggerRain
190dfc6ecd chore: adjust styles and add button reindex (#719)
* chore: adjust styles and add button reindex

* docs: update notes

* style: remove margin bottom
2025-06-29 13:32:07 +08:00
SteveLauC
316a7940d6 chore: log command execution results (#718)
* chore: log command execution results

* release note
2025-06-29 10:46:47 +08:00
SteveLauC
acfc1bb32d feat: interface reindex_applications() (#704)
* feat: impl re-indexing applications

* drop pizza engine
2025-06-29 10:27:02 +08:00
ayangweb
c4d178dc2d feat: support back navigation via delete key (#717)
* feat: support back navigation via delete key

* docs: update changelog
2025-06-27 19:17:27 +08:00
ayangweb
6333c697d5 refactor: support large preview for extensions (#716) 2025-06-27 17:30:49 +08:00
ayangweb
810541494f refactor: update extension detail page ui (#715) 2025-06-27 15:07:34 +08:00
ayangweb
e45dc2acbe fix: context menu search not working (#713) 2025-06-27 14:18:54 +08:00
ayangweb
2d1ccb9744 refactor: improve layout of the extension list (#714) 2025-06-27 14:18:32 +08:00
SteveLauC
406f3b31e9 chore: change extension store request URL to default coco server (#712) 2025-06-27 10:40:12 +08:00
ayangweb
f51dd81014 refactor: optimized some issues with extensions (#711) 2025-06-27 10:22:51 +08:00
SteveLauC
3b38cbfb6c chore: update category name and icon (#710) 2025-06-27 10:16:27 +08:00
ayangweb
a4483ba277 fix: some input fields couldn’t accept spaces (#709)
* fix: some input fields couldn’t accept spaces

* docs: update changelog

* update
2025-06-27 10:16:02 +08:00
ayangweb
bf46979b80 refactor: remove special character filtering and clean up related code (#708) 2025-06-27 10:08:33 +08:00
ayangweb
070f171ad4 refactor: update context menu color for the delete action (#707) 2025-06-27 09:43:35 +08:00
ayangweb
3180704a0d refactor: show all extensions by default in the extension store (#706) 2025-06-27 09:36:20 +08:00
SteveLauC
b3f68697ce feat: impl extension store (#699)
Implements extension store so that users can install extensions from a GUI interface


---------

Co-authored-by: ayang <473033518@qq.com>
2025-06-26 18:40:33 +08:00
BiggerRain
69d2b4b834 chore: add message for latest version check (#703)
* chore: add message for latest version check

* docs: update notes
2025-06-25 10:38:38 +08:00
BiggerRain
6837286061 feat: add manual check for updates (#701)
* feat: add check for update

* feat: add Check for Updates

* docs: update notes

* build: build bundle test

* docs: update notes

* chore: recovering files
2025-06-19 20:58:54 +08:00
ayangweb
a431ead22a feat: support Tab and Enter for delete dialog buttons (#700)
* feat: support `Tab` and `Enter` for delete dialog buttons

* docs: update changelog

* refactor: update
2025-06-19 08:59:01 +08:00
ayangweb
7ec41dfe80 refactor: request data when service is available (#698) 2025-06-18 15:47:49 +08:00
ayangweb
06053e9fd9 refactor: getting service info only when a profile is available (#697)
* refactor: getting service info only when a profile is available

* refactor: update
2025-06-18 14:47:21 +08:00
Medcl
70b048fba3 fix: take coco server back on refresh (#696)
* fix: take coco server back on refresh

* chore: update release notes:
2025-06-18 13:33:59 +08:00
ayangweb
45083f829b refactor: optimized the style of the drop-down selection box (#695)
* refactor: optimized the style of the drop-down selection box

* refactor: update
2025-06-17 18:15:40 +08:00
SteveLauC
e4f6fb8e98 fix: toggle extension should register/unregister hotkey (#691) 2025-06-17 16:56:06 +08:00
BiggerRain
ee182b22da chore: keeping windows and documents safe (#694) 2025-06-17 15:39:18 +08:00
BiggerRain
a37e22c227 fix: quick ai state synchronous (#693)
* fix: quick ai state synchronous

* docs: update notes
2025-06-17 15:38:39 +08:00
BiggerRain
d75ab1018d chore: improve server list selection with enter key (#692)
* chore: server list enter selected

* docs: update notes

* chore: remove log
2025-06-17 09:36:04 +08:00
Medcl
40ad066e69 refactor: refactoring search api (#679)
* refactor: refactoring search api

* chore: interface type

* chore: interface type

* refactor: assistant search

* refactor: arrays into multiple fields

* refactor: update

* feat: search to add fuzziness to 5

* refactor: update

* chore: update release notes

---------

Co-authored-by: rain9 <15911122312@163.com>
Co-authored-by: ayang <473033518@qq.com>
Co-authored-by: ayangweb <75017711+ayangweb@users.noreply.github.com>
2025-06-17 09:31:43 +08:00
BiggerRain
a2a5a9f8fe chore: continue to chat page display (#690)
* chore: Continue to chat page display

* docs: update notes
2025-06-16 18:02:47 +08:00
SteveLauC
5fd9339e56 refactor: use author/ext_id as extension unique identifier (#643)
* refactor: use author/ext_id as extension unique identifier

* refactor: refactoring extended component interfaces and logic

* refactor: update

* style: remove console

* refactor: update

* drop pizza engine

* refactor: restore hotkey upon start no matter if the ext is enabled or not

* chore: release note

---------

Co-authored-by: ayang <473033518@qq.com>
2025-06-16 10:52:01 +08:00
Hardy
a8a9208b1f fix: no make target with project (#689)
* fix: no make with project

* chore: set working directory

---------

Co-authored-by: hardy <luohf@infinilabs.com>
2025-06-13 22:17:37 +08:00
medcl
8c9a2ff441 v0.5.0 2025-06-13 19:28:38 +08:00
Medcl
2251b0af95 chore: update release notes (#687) 2025-06-13 18:37:47 +08:00
BiggerRain
560a12ab93 fix: search & chat dispaly (#686) 2025-06-13 18:18:46 +08:00
ayangweb
2ff66c0b91 fix: arrow inserting escape sequences (#683)
* fix: arrow inserting escape sequences

* fix build

* docs: update changelog

---------

Co-authored-by: Steve Lau <stevelauc@outlook.com>
2025-06-13 18:06:21 +08:00
ayangweb
ef4a184233 refactor: optimize the operation of the small assistant on the secondary page (#685)
* refactor: optimize the operation of the small assistant on the secondary page

* refactor: update
2025-06-13 16:13:31 +08:00
ayangweb
8422bc03e7 refactor: optimize the timing of arrow key triggers on secondary pages (#684) 2025-06-13 15:52:20 +08:00
BiggerRain
370113129c fix: web component start page (#681) 2025-06-13 15:17:52 +08:00
ayangweb
cb758ef452 feat: context menu support for secondary pages (#680)
* feat: context menu support for secondary pages

* docs: update changelog
2025-06-13 15:07:05 +08:00
ayangweb
12b9b4bb81 refactor: blocking the default behavior of the tab key (#678)
* refactor: blocking the default behavior of the tab key

* refactor: update

* refactor: update

* refactor: update
2025-06-13 14:19:27 +08:00
BiggerRain
562db19f16 fix: filter services for unlogged-in users (#677) 2025-06-13 11:04:58 +08:00
ayangweb
dc5cd9aecb fix: fix problem with up and down key indexing (#676)
* fix: fix problem with up and down key indexing

* refactor: update

* docs: update changelog
2025-06-13 10:39:27 +08:00
BiggerRain
0b018cd24f chore: search & deep think & mcp (#675)
* fix: keep line breaks

* chore: search & deep think & mcp
2025-06-12 22:06:48 +08:00
BiggerRain
2ed22d3d7c fix: keep line breaks (#674) 2025-06-12 18:20:44 +08:00
BiggerRain
4ce9561eb7 style: safari styles (#673) 2025-06-12 14:50:02 +08:00
BiggerRain
3aeb39b3af refactor: optimize global state synchronization (#672)
* refactor: optimize global state synchronization

* refactor: reconstruct the language change processing logic

---------

Co-authored-by: ayang <473033518@qq.com>
2025-06-12 14:45:33 +08:00
BiggerRain
27e99d4629 fix: web assistant list (#671) 2025-06-12 11:28:10 +08:00
ayangweb
df70276a54 refactor: ai assistant hides the copy menu (#670)
* refactor: ai assistant hides the copy menu

* style: remove console
2025-06-12 10:39:37 +08:00
BiggerRain
6553a8f5d3 chore: add special character filtering (#668)
* chore: add special character filtering

* docs: update notes
2025-06-12 10:31:15 +08:00
ayangweb
4ebbc9ec6e refactor: improved ai overview and ai quick access blank issue (#669)
* refactor: improved ai overview and ai quick access blank issue

* refactor: update
2025-06-12 10:30:41 +08:00
BiggerRain
4208633556 fix: Fix Special Character input (#667) 2025-06-11 17:50:39 +08:00
ayangweb
fc43fbe798 refactor: improve AI assistant interaction logic and Tab key handling (#666)
* refactor: improve AI assistant interaction logic and Tab key handling

* refactor: update

* style: remove
2025-06-11 17:49:05 +08:00
ayangweb
b5bb9105d4 refactor: re-enable the service to get a list of assistants (#665) 2025-06-11 16:28:53 +08:00
BiggerRain
b6ebd6e5f8 fix: web component dispaly (#663)
* fix: web component dispaly

* fix: web component dispaly

* fix: add showChatHistory & connected

* fix: add isCurrentLogin

* chore: add history
2025-06-11 16:28:43 +08:00
ayangweb
22216491b6 refactor: dynamically generated copy button id (#664) 2025-06-11 15:52:49 +08:00
ayangweb
44ca66259c refactor: don't hide pinned window on search result open (#662)
* refactor: don't hide pinned window on search result open

* refactor: update
2025-06-11 15:26:06 +08:00
ayangweb
be3cae36e2 fix: number keys not following settings (#661)
* fix: number keys not following settings

* refactor: remove unused `modifierKey` dependencies

* docs: update changelog
2025-06-11 14:15:32 +08:00
ayangweb
35ea30626f refactor: improve tooltip display in chinese (#660) 2025-06-11 14:01:08 +08:00
BiggerRain
4bcae5cffb fix: delete history (#659) 2025-06-11 13:36:21 +08:00
BiggerRain
76458db8ab chore: remove enter disabled (#658) 2025-06-11 12:10:37 +08:00
BiggerRain
5b41e190d3 chore: add i18n to services (#657) 2025-06-11 11:03:42 +08:00
ayangweb
43ac9a054c refactor: remove the behavior that organizes event bubbling (#656) 2025-06-11 10:14:05 +08:00
BiggerRain
ac485a32cc style: user message styles (#655) 2025-06-10 19:25:54 +08:00
ayangweb
e10908a095 refactor: optimize the timing of the enter key (#654)
* refactor: optimize the timing of the enter key

* fix: remove input element

---------

Co-authored-by: rain <15911122312@163.com>
2025-06-10 19:01:25 +08:00
BiggerRain
78b8908ac8 fix: stop event bubbling (#653) 2025-06-10 18:22:54 +08:00
ayangweb
3c54cb84a8 refactor: filter unavailable servers (#652) 2025-06-10 17:37:57 +08:00
ayangweb
8ed808c591 fix: fix the problem of local path not opening (#650)
* fix: fix the problem of local path not opening

* docs: update changelog

* chore: remove pizza-engine
2025-06-10 17:26:19 +08:00
ayangweb
7a2dde7448 refactor: check if the message block is purely blank (#651) 2025-06-10 17:22:13 +08:00
BiggerRain
65451fc63e style: user message line break (#648) 2025-06-10 15:41:08 +08:00
BiggerRain
5d108a46d3 style: differentiate between hover and selected styles (#649) 2025-06-10 15:37:17 +08:00
BiggerRain
f9567c2d46 chore: remove defalut current service (#647) 2025-06-10 14:54:07 +08:00
BiggerRain
da917e6012 fix: web page unmount event (#645)
* fix: web page unmont event

* docs: update notes
2025-06-10 14:28:00 +08:00
ayangweb
335a906674 refactor: refactoring shortcut reset logic and optimizing UI interactions (#646) 2025-06-10 14:27:23 +08:00
ayangweb
a50a636d59 fix: input lost when reopening dialog after search (#644)
* fix: input lost when reopening dialog after search

* docs: update changelog
2025-06-10 11:45:45 +08:00
ayangweb
2dd3f776e6 fix: arrow keys still navigated search when menu opened with Cmd+K (#642)
* fix: arrow keys still navigated search when menu opened with `Cmd+K`

* docs: update changelog
2025-06-10 09:56:27 +08:00
BiggerRain
40f6aa0ccd chore: copy supports http protocol (#639)
* chore: copy supports http protocol

* docs: update notes
2025-06-09 18:12:43 +08:00
ayangweb
4da9e024e0 refactor: update login status when service is not enabled (#638) 2025-06-09 18:11:35 +08:00
ayangweb
c20bba51f5 fix: tab key hides window in chat mode (#641)
* fix: tab key hides window in chat mode

* docs: update changelog
2025-06-09 18:10:56 +08:00
BiggerRain
0a62a2095b fix: add shift line break to chat input (#637) 2025-06-09 15:06:59 +08:00
SteveLauC
5677995185 chore: more logs for the setup process (#634)
* chore: more logs for the setup process

* chore: more logs for the setup process

* chore: more logs for the setup process

* chore: release note
2025-06-09 14:46:06 +08:00
BiggerRain
ec4e5e7d1d fix: remove stopImmediatePropagation event (#636) 2025-06-09 12:05:27 +08:00
BiggerRain
1df5265b1a chore: add onContextMenu event (#629) 2025-06-09 11:57:48 +08:00
ayangweb
fb8a4684dc refactor: improved page content after disabling the service (#635)
* refactor: improved page content after disabling the service

* style: remove unless code

* style: remove unless code
2025-06-09 11:54:44 +08:00
BiggerRain
0b609e570d chore: web component default mode (#627) 2025-06-09 09:54:09 +08:00
BiggerRain
f91f6bdc17 fix: web component set IsDark (#630) 2025-06-07 10:49:16 +08:00
ayangweb
57590f3b57 feat: add internationalized translations of AI-related extensions (#632)
* feat: add internationalized translations of AI-related extensions

* docs: update changelog

* refactor: update
2025-06-07 10:48:55 +08:00
ayangweb
c18f9ea154 refactor: optimized input box logic for transparency (#628) 2025-06-06 17:58:18 +08:00
ayangweb
441875d9b4 refactor: optimize data filtering logic (#626) 2025-06-06 17:20:45 +08:00
ayangweb
eddf9075bb feat: add ai overview minimum number of search results configuration (#625)
* feat: add ai overview minimum number of search results configuration

* docs: update changelog

* style: remove unless code
2025-06-06 17:05:20 +08:00
ayangweb
9eac8f8a8e feat: support right-click actions after text selection (#624)
* feat: support right-click actions after text selection

* docs: update changelog

* feat: support for selecting messages sent by users
2025-06-06 16:43:27 +08:00
ayangweb
515260c43f feat: calculator extension add description (#623)
* feat: calculator extension add description

* docs: update changelog
2025-06-06 15:43:24 +08:00
ayangweb
118de0e80b fix: fix ai overview hidden height before message (#622)
* fix: fix ai overview hidden height before message

* docs: update changelog
2025-06-06 15:30:42 +08:00
SteveLauC
19ce896fdc chore: release note for PR 620 (#621) 2025-06-06 15:17:59 +08:00
SteveLauC
4a41ea5d8b fix: invalid DSL error if input contains multiple lines (#620) 2025-06-06 14:58:45 +08:00
ayangweb
880e1206ce fix: fixed modifier keys not working with continue chat (#619)
* fix: fixed modifier keys not working with continue chat

* docs: update changelog
2025-06-06 14:24:36 +08:00
SteveLauC
1e6d9f9550 fix: do not panic when the datasource specified does not exist (#618)
* fix: do not panic when the datasource specified does not exist

* release note
2025-06-06 14:07:27 +08:00
BiggerRain
ff0faf425f fix: only select history and then set the assistant (#617)
* fix: only select history and then set the assistant

* fix: only select history and then set the assistant
2025-06-06 14:06:49 +08:00
ayangweb
1fbf5d6552 fix: resolved an issue where number keys were not working on the web (#616)
* fix: resolved an issue where number keys were not working on the web

* docs: update changelog
2025-06-06 11:47:38 +08:00
ayangweb
db41e817c3 feat: add key monitoring during reset (#615)
* feat: add key monitoring during reset

* docs: update changelog
2025-06-06 11:23:40 +08:00
BiggerRain
1296755bc5 fix: datasource and mcp data updates (#614) 2025-06-06 11:11:33 +08:00
ayangweb
d410f20864 refactor: remove footer from standalone history window (#613) 2025-06-06 11:11:06 +08:00
ayangweb
61d0a3b79a fix: fix chat log update and sorting issues (#612)
* fix: fix chat log update and sorting issues

* docs: update changelog
2025-06-06 10:52:47 +08:00
BiggerRain
b24319b649 fix: datasource refresh status feedback (#611) 2025-06-06 10:51:31 +08:00
BiggerRain
3c0fb24548 fix: shortcut key prompts cannot be hidden (#610) 2025-06-06 10:51:09 +08:00
BiggerRain
2fcbed0381 fix: i18n is not accurate (#609) 2025-06-06 10:50:36 +08:00
SteveLauC
7444347e0c docs: new doc for macOS (#608) 2025-06-05 19:23:14 +08:00
SteveLauC
725ce042de docs: remove the hyperlink in title (#607) 2025-06-05 18:26:09 +08:00
BiggerRain
3b67de5387 chore: initialize current assistant from history (#606)
* chore: the last assistant in history is set as current

* docs: update notes

* docs: update notes
2025-06-05 08:54:39 +08:00
SteveLauC
9b53a026ff refactor: execute Calculator/Extension search() in spawn_blocking (#601) 2025-06-04 18:45:17 +08:00
ayangweb
9ea7dbf3aa fix: resolve regex error on older macOS versions (#605)
* fix: fix: resolve regex error on older macOS versions

* docs: update changelog

* style: remove unless code

* style: remove unless code
2025-06-04 18:38:34 +08:00
BiggerRain
55622911ac style: Switch selected color in dark mode (#604) 2025-06-04 14:10:17 +08:00
BiggerRain
92f78ad08c fix: new chat assistant id not found (#603)
* fix: new chat assistant id

* docs: update notes
2025-06-04 13:06:30 +08:00
ayangweb
f690dbaab2 refactor: web use the default icon for now (#602) 2025-06-04 11:30:59 +08:00
ayangweb
210efe763d fix: fixed issue with incorrect login status (#600)
* fix: fixed issue with incorrect login status

* style: remove unless code

* fix: user avatar error

* refactor: replace with default svg icon

* style: remove unless code

* docs: update changelog

---------

Co-authored-by: rain <15911122312@163.com>
2025-06-04 10:24:56 +08:00
BiggerRain
f23498afa0 fix: web icon isAbsolute (#599) 2025-06-03 19:28:26 +08:00
BiggerRain
a80a5d928f fix: app icon load console error (#598) 2025-06-03 15:47:58 +08:00
ayangweb
b733bb5516 feat: ai overview support is enabled with shortcut (#597)
* feat: ai overview support is enabled with shortcut

* docs: update changelog
2025-06-03 15:01:29 +08:00
ayangweb
5046754534 refactor: optimized loading of font icons on the web side (#596)
* refactor: optimized loading of font icons on the web side

* refactor: update
2025-06-03 11:22:22 +08:00
SteveLauC
f557f7e780 chore: set log level to coco_lib=trace for built Coco app (#595) 2025-06-03 11:18:28 +08:00
BiggerRain
18feb2d690 fix: set chat message assistant (#594) 2025-06-03 10:53:01 +08:00
BiggerRain
af59f2fe9f fix: web component removes redundant parameters (#593) 2025-06-03 10:35:26 +08:00
BiggerRain
5e1bb54d5e chore: web component adds variable process (#592) 2025-06-03 10:12:22 +08:00
Hardy
33fa516aad fix: rustup for i688 (#590)
Co-authored-by: hardy <luohf@infinilabs.com>
2025-06-01 07:19:28 +08:00
Hardy
d2c1cf513d chore: use version fix (#591)
Co-authored-by: hardy <luohf@infinilabs.com>
2025-05-31 20:22:53 +08:00
Hardy
f81bec8403 chore: rollback publish (#589)
* chore: rollback publish

* chore: set toolchain

---------

Co-authored-by: hardy <luohf@infinilabs.com>
2025-05-31 16:29:08 +08:00
medcl
cce956ac15 v0.5.2 2025-05-31 16:06:12 +08:00
Hardy
0d1174c8dd chore: fix ci publish error (#588)
* chore: fix ci publish error

* docs: update release notes

---------

Co-authored-by: hardy <luohf@infinilabs.com>
2025-05-31 16:05:28 +08:00
ayangweb
e0258dc2fa fix: fixed issue with quick ai access making multiple requests at once (#586) 2025-05-31 15:56:35 +08:00
medcl
310a70838b v0.5.1 2025-05-31 15:55:33 +08:00
Hardy
94d7f809d2 chore: add ssh private key for pizza engine (#587)
Co-authored-by: hardy <luohf@infinilabs.com>
2025-05-31 15:51:20 +08:00
medcl
e1d1bc2684 v0.5.0 2025-05-31 15:01:02 +08:00
Medcl
a9e3bb3eee chore: ignore throttle message (#585) 2025-05-31 11:07:01 +08:00
Medcl
d184851e3b chore: remove icon field before ask ai (#584) 2025-05-31 10:03:19 +08:00
BiggerRain
c9b785ccf3 fix: sent chat once more (#583) 2025-05-31 08:53:37 +08:00
Medcl
4c5ae8c718 chore: update error handling (#582)
* chore: update error handling

* chore: update min osx version
2025-05-31 08:50:27 +08:00
Hardy
8a7f7bc708 chore: add pizza feature for release (#581)
Co-authored-by: hardy <luohf@infinilabs.com>
2025-05-30 22:28:44 +08:00
ayangweb
3d44d10048 refactor: remove unused disabledExtensions related code (#580) 2025-05-30 19:41:51 +08:00
BiggerRain
97d880ea27 fix: useScript error (#579) 2025-05-30 19:41:29 +08:00
Medcl
6c53056edd chore: update default coco server (#578) 2025-05-30 19:27:41 +08:00
ayangweb
a6fd2ebd16 fix: fix web carriage return not jumping (#577) 2025-05-30 18:41:58 +08:00
SteveLauC
b509176572 fix: make extension search source respect parameter datasource (#576) 2025-05-30 18:39:09 +08:00
ayangweb
17f2bcf7a8 fix: fix the problem that web cannot click on the jump (#575) 2025-05-30 18:22:18 +08:00
ayangweb
c471a83821 feat: support third party extensions (#572)
* refactor: support third party extensions

* fix tests

* fix: assistant_get error

* aaa

* bbb

* ccc

* ddd

* fix: aa

* fix: aa

* sss

* fix:asds

* eee

* refactor: loosen restriction of query string length

* fix: input auto

* feat: add ai overview trigger condition configuration

* refactor: continue chatting to select the corresponding mini-helper

* chore: settings width height

* aaa

---------

Co-authored-by: Steve Lau <stevelauc@outlook.com>
Co-authored-by: rain <15911122312@163.com>
2025-05-30 17:18:52 +08:00
SteveLauC
51b0a2a545 refactor: remove thread app list synchronizer as it leaks memory on macOS (#573) 2025-05-29 17:55:24 +08:00
BiggerRain
baded2af1e refactor: search result related components (#571)
* refactor: search result related components

* refactor: search result related components

* docs: update notes

* refactor: search result related components

* fix: ArrowLeft error

* chore: remove log

* fix: ask ai
2025-05-29 16:01:52 +08:00
BiggerRain
2b21426355 refactor: input box related components (#568)
* refactor: input box components

* chore: change variable name

* docs: update notes

* fix: shortcut key failure issue
2025-05-28 12:29:28 +08:00
BiggerRain
8edc938426 chore: only show available servers in chat (#570)
* chore: add server available

* docs: update notes

* docs: update notes
2025-05-28 10:51:25 +08:00
Medcl
fa919bee11 chore: mark unavailable server to offline on refresh info (#569)
* chore: mark server offline on refresh info

* chore: update release notes
2025-05-28 10:43:53 +08:00
Medcl
50f1e611c3 refactor: refactoring rerank feature (#567)
* refactor: refactoring rerank feature

* chore: remove unused code

* chore: pull back unrelated changes
2025-05-27 18:27:53 +08:00
BiggerRain
4c3cf28012 chore: assistant chat placeholder & refactor input box components (#566)
* chore: input placeholder

* chore: add assitant

* impl assistant_get_multi()

* chore: add assitant

* refactor: input box components

* chore: ask ai search placeholder

* chore: ask ai search placeholder

* docs: update notes

---------

Co-authored-by: Steve Lau <stevelauc@outlook.com>
2025-05-27 16:29:43 +08:00
BiggerRain
89fcc67222 fix: assistant list (#563)
* fix: assistant list

* fix: assistant list

* fix: assistant list

* fix: assistant list
2025-05-27 09:24:58 +08:00
Medcl
33c9ce67df chore: remove pizza deps (#565) 2025-05-27 09:09:17 +08:00
SteveLauC
c6dadfd83e ci: deny dep pizza-engine (#564)
* ci: deny dep pizza-engine

* ci: set PWD to cargo workspace
2025-05-27 08:59:46 +08:00
Medcl
e707a8b5c7 chore: rerank support ignore case (#562)
* chore: rerank support ignore case

* chore: remove unused deps
2025-05-26 19:24:01 +08:00
BiggerRain
5c5364974a chore: web component start page config (#560)
* chore: web component start page config

* chore: web component start page config

* docs: update notes
2025-05-26 18:54:33 +08:00
Medcl
9d3e3e8dde feat: rerank search results (#561)
* feat: rerank search results

* chore: update release notes
2025-05-26 18:54:06 +08:00
BiggerRain
e065ba749f chore: assistant keyboard events and mouse events (#559)
* chore: assistant keyboard events and mouse events

* docs: update notes
2025-05-26 15:44:05 +08:00
ayangweb
2dd8e3160c fix: resolved navigation error on continue chat action (#558)
* fix: resolved navigation error on continue chat action

* docs: update changelog
2025-05-26 10:56:29 +08:00
ayangweb
6aeecfe3ac feat: add quick AI access to search mode (#556)
* feat: add quick AI access to search mode

* feat: add aI assistant quick access

* refactor: adjusting lodash-es import location to optimize code structure

* docs: update changelog

* fix: fix the logic of assigning serverId in AskAi component

* refactor: optimized layout

* refactor: optimized some issues
2025-05-23 18:14:41 +08:00
SteveLauC
334e29d69b chore: add make cmd dev-build-with-pizza (#555) 2025-05-23 16:43:38 +08:00
BiggerRain
382f89ace0 fix: independent chat app has no datasources (#554)
* fix: independent chat window has no data

* docs: update notes
2025-05-23 16:42:35 +08:00
BiggerRain
32c7cc5060 fix: suggestion list position (#553)
* fix: suggestion List position

* docs: update notes
2025-05-23 15:31:27 +08:00
BiggerRain
c13151d69e fix: the scroll button is not displayed by default (#552)
* fix: the scroll button is not displayed by default

* docs: update notes
2025-05-23 14:53:57 +08:00
BiggerRain
07c4ab03b5 fix: secondary page cannot be searched (#551)
* fix: secondary page cannot be searched

* docs: update notes
2025-05-22 19:45:28 +08:00
BiggerRain
cf3f2affa5 fix: history list height (#550)
* fix: history list height

* docs: update notes
2025-05-22 16:28:11 +08:00
BiggerRain
401832ad43 chore: logout update server profile (#549)
* chore: logout update server profile

* docs: update notes
2025-05-22 11:53:23 +08:00
Medcl
6a6f48d2fc chore: mark server offline on user logout (#546)
* chore: mark server offline on user logout

* update release notes
2025-05-22 11:37:20 +08:00
BiggerRain
8a6c90d124 chore: add global login judgment (#544)
* chore: add global login judgment

* docs: update notes
2025-05-22 10:59:46 +08:00
BiggerRain
34acecbcb0 chore: add assistant count (#542)
* fix: switch server assistant and session session unchanged

* docs: update notes

* fix: add server error

* chore: add assistant count

* docs: update notes
2025-05-21 15:29:04 +08:00
SteveLauC
4474212b7d chore: dead code cleanup (#543) 2025-05-21 14:40:38 +08:00
Medcl
1187b641d4 refactor: refactoring search error (#541)
* refactor: refactoring search error

* chore: update release notes
2025-05-21 14:27:17 +08:00
BiggerRain
ef8cd569e4 fix: switch server assistant and session session unchanged (#540)
* fix: switch server assistant and session session unchanged

* docs: update notes
2025-05-21 11:34:03 +08:00
BiggerRain
5ef06bfc95 fix: service switching error (#539)
* fix: service switching error

* build: build error

* chore: chat content can be copied

* docs: update notes

* fix: service switching error

* chore: change to send cancel event to ws_cancel

* chore: add ws-cancel

---------

Co-authored-by: medcl <m@medcl.net>
2025-05-21 09:04:57 +08:00
SteveLauC
2b59addb08 fix: panic when fetching app metadata on Windows (#538)
* fix: panic when fetching app metadata on Windows

* release note
2025-05-21 09:04:08 +08:00
BiggerRain
ee750620f2 refactor: service info related components (#537)
* refactor: service info related components

* docs: update notes

* refactor: chat header service status
2025-05-20 17:02:10 +08:00
Medcl
acc3b1a0d2 chore: skip register server that not logged in (#536)
* chore: update logging message

* chore: skip register server that not logged in

* chore: update logging message

* chore: update release notes
2025-05-20 15:10:27 +08:00
SteveLauC
4372747014 feat: dynamic log level via env var COCO_LOG (#535) 2025-05-20 12:54:07 +08:00
BiggerRain
ee531209aa fix: server image loading failure (#534)
* fix: server image loading failure

* docs: update notes
2025-05-20 09:31:54 +08:00
BiggerRain
ee0bbce3e2 style: search error styles (#533)
* style: search error styles

* docs: update notes
2025-05-19 19:54:34 +08:00
SteveLauC
7eccf99f92 fix: do not pass whitespace-only strings to Calculator expr evaluation lib (#532) 2025-05-19 19:24:32 +08:00
SteveLauC
5044a98bb7 fix: app hotkey hanlder invoked twice (key pressed and released) (#531) 2025-05-19 18:40:44 +08:00
SteveLauC
72165812bf refactor: ignore the error happens while indexing a specific app (#530)
* refactor: ignore the error happens while indexing a specific app

* refactor: ignore the error happens while indexing a specific app
2025-05-19 17:28:13 +08:00
BiggerRain
f9c1be8517 fix: app icon & category icon (#529) 2025-05-19 17:24:51 +08:00
BiggerRain
71ce23ef21 style: history component styles (#528)
* style: history component styles

* docs: update notes

* build: build & publish web componet version 1.2.1

* build: build & publish web componet version 1.2.2
2025-05-19 16:56:00 +08:00
Medcl
3e6041cbd8 chroe: update minimum macOS version to 10 (#527) 2025-05-18 15:06:06 +08:00
SteveLauC
0b9e158b55 fix: panic caused by an unwrap() (#526) 2025-05-17 18:44:17 +08:00
BiggerRain
688ced3fc3 build: build & publish web component (#524) 2025-05-17 16:53:17 +08:00
BiggerRain
16e0382a8b docs: update release notes (#525) 2025-05-17 16:52:26 +08:00
BiggerRain
91c9cd5725 fix: show only enabled datasource & MCP list (#523)
* fix: show only enabled datasource & MCP list

* docs: update notes

* fix: show only enabled datasource & MCP list
2025-05-17 12:01:18 +08:00
ayangweb
7f3e602bb3 feat: add a component for text reading aloud (#522)
* feat: add a component for text reading aloud

* docs: update changelog
2025-05-16 16:21:57 +08:00
BiggerRain
5e9d41ea5c fix: datasource & MCP list synchronization update (#521)
* fix: datasource & MCP list update

* docs: update notes

* docs:update notes
2025-05-16 15:09:51 +08:00
Medcl
8bdb93d813 refactor: refactoring icon component (#514)
* chore: try to fix icon for insecure-tls deployment

* chore: handling icon resource loading errors

* refactor: refactored icon component

* chore: update release notes

---------

Co-authored-by: rain <15911122312@163.com>
2025-05-16 12:03:43 +08:00
ayangweb
690e6a3225 refactor: optimizing list styles in markdown content (#520)
* refactor: optimizing list styles in markdown content

* docs: update changelog

* style: remove unless code
2025-05-16 10:21:41 +08:00
ayangweb
111d9bddca style: remove useless code (#519) 2025-05-16 09:17:41 +08:00
ayangweb
7645b3e736 feat: add AI summary component (#518)
* feat: add AI summary component

* docs: update changelog

* refactor: update
2025-05-15 18:27:17 +08:00
Medcl
ac21074db6 fix: loading chat history for potential empty attachments (#516)
* fix: loading chat history for potential empty attachments

* chore: update release notes
2025-05-15 15:38:46 +08:00
BiggerRain
496ae025d8 style: chat input icons show (#515)
* style: chat input icons show

* style: chat input icons show

* docs: update notes
2025-05-15 15:26:49 +08:00
SteveLauC
ac5a196746 refactor: store setting allowSelfSignature in backend (#512)
* refactor: store setting allowSelfSignature in backend

* refactor: store setting allowSelfSignature in backend

* refactor: only reinit client when config gets updated

* refactor: docking api

* unused import cleanup

---------

Co-authored-by: ayang <473033518@qq.com>
Co-authored-by: ayangweb <75017711+ayangweb@users.noreply.github.com>
2025-05-15 09:17:03 +08:00
BiggerRain
aa99588001 style: modify the style of the search input box (#513)
* style: modify the style of the search input box

* build: build error
2025-05-15 08:54:42 +08:00
BiggerRain
163df77e8a fix: fixed the newly created session has no title when it is deleted (#511)
* fix: fixed the issue that the newly created session has no title when it is deleted

* docs: update notes
2025-05-14 16:14:57 +08:00
ayangweb
21509f35e5 refactor: optimize the style problem of icons (#510) 2025-05-14 16:09:51 +08:00
ayangweb
7bf59aa259 feat: add option to allow self-signed certificates (#509)
* feat: add option to allow self-signed certificates

* docs: update changelog
2025-05-14 16:00:40 +08:00
ayangweb
4aa377e486 refactor: optimized the modification operation of the numeric input box (#508)
* refactor: optimized the modification operation of the numeric input box

* docs: update changelog
2025-05-14 15:03:17 +08:00
ayangweb
feb716039c refactor: changing the timing of app list loading (#507) 2025-05-14 11:49:53 +08:00
BiggerRain
448d2a6069 refactor: optimizing the code (#505)
* refactor: optimizing the code

* docs: update notes
2025-05-14 10:59:18 +08:00
Medcl
c31a4aa52a feat: websocket support self-signed TLS (#504)
* feat: websocket support self-signed TLS

* chore: update release notes

* chore: remove unused comments
2025-05-14 10:07:49 +08:00
SteveLauC
73ac29ef3b refactor: fetch app list in settings in real time (#498) 2025-05-13 18:16:40 +08:00
Medcl
3cd73f13ab chore: update readme (#503) 2025-05-13 18:13:39 +08:00
Medcl
95ccbaec3e fix: several issues around search (#502)
* fix: several issues around search

* chore: update release notes
2025-05-13 18:12:57 +08:00
BiggerRain
d52ce481f9 feat: the search input box supports multi-line input (#501)
* feat: the search input box supports multi-line input

* docs: update notes
2025-05-13 16:26:54 +08:00
BiggerRain
573e1cf038 chore: add clear monitoring & cache calculation to optimize performance (#500)
* chore: add clear monitoring & cache calculation to optimize performance

* docs: update notes
2025-05-13 14:07:06 +08:00
BiggerRain
5162604cfd chore: UpdateApp component loading location (#499)
* chore: UpdateApp component loading location

* docs: update notes
2025-05-13 11:40:29 +08:00
ayangweb
e38053682d refactor: optimize styling issues with chat content (#497)
* refactor: optimize styling issues with chat content

* style: changing the import order
2025-05-13 11:24:07 +08:00
BiggerRain
018ec9e4ed chore: greetings show hidden logic (#496)
* chore: greetings show hidden logic

* docs: update notes
2025-05-13 11:01:05 +08:00
BiggerRain
f9e5c6cc28 chore: search and MCP show hidden logic (#494)
* chore: enabled_by_default search & MCP

* chore: add enabled param judge

* chore: add enabled param judge

* chore: add enabled param judge

* docs: update notes

---------

Co-authored-by: ayangweb <75017711+ayangweb@users.noreply.github.com>
2025-05-13 10:44:49 +08:00
ayangweb
6bb64e92d9 feat: the chat content has added a button to return to the bottom (#495)
* feat: the chat content has added a button to return to the bottom

* docs: update changelog

* refactor: optimization effect
2025-05-13 10:36:02 +08:00
SteveLauC
7962c329c7 chore: add ~/Applications to search path on macOS (#493)
* chore: add ~/Applications to search path on macOS

* changelog entry
2025-05-12 18:47:25 +08:00
ayangweb
dd6bd2093d refactor: optimize the style of the sidebar (#492)
* refactor: optimize the style of the sidebar

* refactor: adjust z-index value to increase sidebar hierarchy
2025-05-12 18:08:50 +08:00
SteveLauC
25d998a41c fix: duplicate flatpak applications (#491) 2025-05-12 17:47:16 +08:00
BiggerRain
3cfb03dd49 feat: the chat input box supports multi-line input (#490)
* chore: chat input

* feat: the chat input box supports multi-line input

* docs: update notes

* chore: remove env record

* chore: remove debug
2025-05-12 16:03:49 +08:00
SteveLauC
386b9cc48b fix: panic caused memory allocation failure on Linux (#489) 2025-05-12 15:27:49 +08:00
ayangweb
006b679386 refactor: refactor the content style of the extended page (#488) 2025-05-12 14:55:36 +08:00
SteveLauC
d47fb3cbc6 refactor: set up tauri-plugin-log as the logger (#487)
* refactor: set up tauri-plugins-log as the logger

* refactor: captures the front-end promise and outputs it to the log

---------

Co-authored-by: ayang <473033518@qq.com>
2025-05-12 09:33:37 +08:00
SteveLauC
26f71cff08 chore: remove dependency pizza engine (#486)
* chore: remove dep pizza engine

* style: fmt
2025-05-11 14:44:16 +08:00
SteveLauC
ae8f95e19c chore: use ssh instead of https to pull pizza_engine (#485) 2025-05-09 18:58:47 +08:00
Medcl
4c49daf510 chore: refine wording for search failure (#484)
* chore: refine wording on search failure

* chore: update release notes
2025-05-09 18:14:18 +08:00
SteveLauC
8d2528e521 refactor: use pizza_engine for app search (#346)
* refactor: use pizza_engine for app search

* refactor: do not break the build when pizza_engine is unavailable
2025-05-09 17:54:58 +08:00
ayangweb
4895322397 feat: history list add put away button (#482)
* feat: history list add put away button

* docs: update changelog
2025-05-09 16:17:02 +08:00
BiggerRain
a8a4d435fc chore: debug datasource component (#483) 2025-05-09 16:16:12 +08:00
ayangweb
1c0335feb4 fix: fix the focusing problem of the input box in windows (#481) 2025-05-07 18:09:19 +08:00
ayangweb
8498578425 feat: support for snapshot version updates (#480)
* feat: support for snapshot version updates

* docs: update changelog
2025-05-07 16:43:44 +08:00
Hardy
326e161505 chore: add github action build arm64 platform (#479)
Co-authored-by: hardy <luohf@infinilabs.com>
2025-05-07 10:50:39 +08:00
BiggerRain
e96e6b4a89 build: solve build error (#477)
* build: solve build error

* build: solve build error
2025-05-01 14:46:50 +08:00
BiggerRain
853ea38058 fix: solve the problem of modifying the assistant in the chat (#476)
* refactor: refactored chat code

* fix: Solve the problem of modifying the assistant in the chat

* docs: update notes

* docs: update notes
2025-04-30 16:24:14 +08:00
BiggerRain
4e127f8cdc chore: adjust list error message (#475)
* chore: adjust list error message

* docs: update notes
2025-04-30 09:01:31 +08:00
ayangweb
51ada19d42 refactor: optimize the mode display of the first launched window (#474) 2025-04-29 19:07:03 +08:00
ayangweb
86f3741302 docs: update changelog (#473) 2025-04-29 17:47:14 +08:00
ayangweb
bb50b150c0 feat: supports Shift + Enter input box line feeds (#472) 2025-04-29 17:44:17 +08:00
ayangweb
a092354fee feat: supports setting of out-of-focus transparency on top (#470)
* feat: supports setting of out-of-focus transparency on top

* docs: update changelog

* refactor: optimize translation content

* docs: update changelog
2025-04-29 17:08:01 +08:00
SteveLauC
2ffbb79358 docs: document how to install Coco app on Ubuntu (#471) 2025-04-29 17:05:16 +08:00
ayangweb
661b5d1b77 feat: check or enter to close the list of assistants (#469)
* feat: check or enter to close the list of assistants

* docs: update changelog
2025-04-29 15:16:46 +08:00
medcl
47d2e46b72 v0.4.0 2025-04-28 17:58:37 +08:00
ayangweb
414bc78aaf feat: updated to include error message (#465)
* feat: updated to include error message

* docs: update changelog
2025-04-28 16:41:15 +08:00
ayangweb
9fd4a16df3 fix: fixed carriage return problem with chinese input method (#464)
* fix: fixed carriage return problem with chinese input method

* docs: update changelog
2025-04-28 16:36:20 +08:00
BiggerRain
0e9e8bf653 fix: deep_think param type (#463)
* fix: deep_think param type

* chore: deep think params
2025-04-28 12:56:55 +08:00
BiggerRain
c14b9fa0be chore: historical message corresponding assistant (#462)
* chore: historical message corresponding assistant

* chore: addjust code
2025-04-28 11:36:44 +08:00
ayangweb
8477c7ce95 refactor: optimizing markdown styles (#461)
* refactor: optimizing markdown styles

* style: delete test code
2025-04-28 11:34:31 +08:00
ayangweb
3e48eae749 refactor: optimized getting data sources and mcp lists (#460) 2025-04-28 09:52:39 +08:00
ayangweb
5764b72f1e refactor: modifying the macos window hierarchy (#459) 2025-04-28 09:35:22 +08:00
Hardy
bff86c327a chore: update release notes for publish 0.4.0-2018 (#455)
Co-authored-by: github-actions <github-actions@github.com>
2025-04-27 22:21:28 +08:00
BiggerRain
e60915443a build: build web 1.1.14 (#456) 2025-04-27 22:19:38 +08:00
ayangweb
c86c768960 refactor: optimizing parameter variables (#454) 2025-04-27 21:49:29 +08:00
ayangweb
a6a84f3df5 refactor: mcp list adaptation font icon (#453) 2025-04-27 21:41:56 +08:00
ayangweb
0a231b80d0 refactor: fixing and optimizing known bugs (#452)
* refactor: optimized the color of error messages

* refactor: optimize the selection problem in the list of little helpers

* refactor: updated translation and internationalization support for extension modules

* refactor: optimize shortcut key display

* fix: fix startup page style issues
2025-04-27 20:12:46 +08:00
ayangweb
5272c3dab9 refactor: rename queryTimeoutRenamed to querySourceTimeout (#451) 2025-04-27 20:10:54 +08:00
SteveLauC
256262ec2e refactor: rename the key used to store queryTimeout (#450) 2025-04-27 16:54:09 +08:00
BiggerRain
4508c292eb fix: Solves the issue of bottom toolbar overlapping content in mobile Safari (#448)
* style: adjust style

* fix: close Splash
2025-04-26 14:27:00 +08:00
Medcl
f4a3838844 chore: update default query timeout to 500ms (#447) 2025-04-26 11:29:39 +08:00
ayangweb
6e07cacae2 reactor: replacing the default key (#446) 2025-04-25 22:57:45 +08:00
ayangweb
191f34905e feat: add useEscape and useModifierKeyPress hooks (#445) 2025-04-25 22:30:18 +08:00
BiggerRain
f876fc24f2 fix: herder width (#444) 2025-04-25 19:03:30 +08:00
ayangweb
05f1459f8d refactor: removed the selection effect (#443) 2025-04-25 17:18:34 +08:00
ayangweb
78a7bfb4c4 feat: add shortcut key conflict hint and reset function (#442)
* feat: add shortcut key conflict hint and reset function

* docs: update changelog
2025-04-25 17:08:38 +08:00
ayangweb
9078c99e25 feat: right-click menu search supports shortcuts (#441) 2025-04-25 15:06:10 +08:00
BiggerRain
a044642636 chore: icons border color (#440)
* chore: icons border color

* build: publish web 1.1.9
2025-04-25 14:54:32 +08:00
ayangweb
0f18c0a597 feat: add MCP search-related shortcut configurations (#439) 2025-04-25 14:47:34 +08:00
ayangweb
86836bf756 refactor: optimized the up and down keys (#438)
* refactor: optimized the up and down keys

* style: remove useless code
2025-04-25 14:19:15 +08:00
ayangweb
70f876fd4a refactor: optimized the logic of esc key handling (#437) 2025-04-25 13:25:19 +08:00
Medcl
3826346fdf chore: update response handle (#433) 2025-04-24 19:01:56 +08:00
BiggerRain
79b998da1b feat: add MCP & call tools (#430)
* feat: add call tools

* feat: add chat call tools

* feat: add MCP & call LLM tools

* docs: update notes

* build: build error

* chore: replace iconfont

* chore: web icon

* chore: add
2025-04-24 19:00:16 +08:00
SteveLauC
839a51bb3c refactor: use the Runtime created by tauri (#436) 2025-04-24 18:15:24 +08:00
ayangweb
f7c7c0cc1e feat: auto becomes semi-transparent when it loses focus (#435) 2025-04-24 18:15:08 +08:00
ayangweb
61e253ca2c refactor: replacing images with SVG to support dynamic colors (#434) 2025-04-24 17:07:53 +08:00
ayangweb
ab16543e65 feat: data sources support displaying customized icons (#432)
* feat: data sources support displaying customized icons

* docs: update changelog
2025-04-24 16:28:34 +08:00
ayangweb
c095ad4d29 feat: ai assistant supports search and paging (#431) 2025-04-24 16:03:34 +08:00
BiggerRain
af63bab7bd fix: web iocn (#429)
* fix: web iocn

* fix: serarch icon

* build: build web 1.1.6

* chore: remove console
2025-04-24 09:38:38 +08:00
ayangweb
80ac8baca5 feat: add chat mode launch page #424 2025-04-23 19:03:09 +08:00
ayangweb
bde658b981 feat: add chat mode launch page (#424) 2025-04-23 18:50:32 +08:00
BiggerRain
4380b56a30 fix: query_coco_fusion params error (#425) 2025-04-23 18:49:42 +08:00
ayangweb
54364565e2 feat: right-click menu support for search (#423)
* feat: right-click menu support for search

* docs: update changelog
2025-04-23 18:42:17 +08:00
BiggerRain
ee4a06b6de feat: web components assistant (#422)
* chore: web components assistant

* chore: web components assistant

* docs: update notes
2025-04-23 18:23:40 +08:00
ayangweb
9715a92f36 refactor: change the selected background color of the item (#421) 2025-04-23 17:54:41 +08:00
BiggerRain
2caeb4090a docs: update README (#418) 2025-04-23 14:49:23 +08:00
ayangweb
983e65ee61 refactor: right-click menu returns to execute whichever one is selected (#417) 2025-04-23 10:30:56 +08:00
BiggerRain
ec37cfe68f fix: current conversation tip (#416) 2025-04-23 10:30:14 +08:00
BiggerRain
db66d81bd0 fix: fixed several search & chat bugs (#412)
* fix: chat error

* chore: add querysource

* refactor: filter query source rather than data source

* fix: fixed several search bugs

* docs: update notes

* feat: chat error

* chore: websocket

* chore: chat

* chore: chat

* fix: history search error

---------

Co-authored-by: Steve Lau <stevelauc@outlook.com>
2025-04-23 00:12:22 +08:00
ayangweb
5b0fdbcb2c feat: support esc to exit right-click menu (#415) 2025-04-22 19:58:47 +08:00
ayangweb
88955e0b95 feat: ai assistant supports shortcuts (#414) 2025-04-22 18:36:59 +08:00
SteveLauC
aee7df608f refactor: use timeout value specified in settings in query_coco_fusion() (#413)
* refactor: allow setting connection_timeout in query_coco_fusion()

* refactor: adding dynamic parameters to a request

* refactor: rename `connection_timeout` to `connectionTimeout`.

* refactor: simplifying object property assignment syntax

* feat: add query timeout function

* refactor: set min query_timeout to 1s

* refactor: rename connection_timeout to query_timeout

* fix: persist the setting entry

---------

Co-authored-by: ayang <473033518@qq.com>
2025-04-22 18:32:20 +08:00
SteveLauC
6d8fa81141 revert: Document constructor changed in #399 (#410) 2025-04-22 16:15:04 +08:00
ayangweb
d67d6645fe feat: automatically selects the first entry after searching (#411)
* feat: automatically selects the first entry after searching

* docs: update changelog

* refactor: remove debug log statements
2025-04-22 16:13:23 +08:00
ayangweb
6329354243 feat: add keyboard event handling and double-click copying (#409)
* feat: add keyboard event handling and double-click copying

* docs: delete duplicate release note entries
2025-04-22 15:01:00 +08:00
ayangweb
3ef5226e11 refactor: add empty data prompt to search scope (#406) 2025-04-22 13:36:00 +08:00
ayangweb
eebf49d7e0 refactor: optimize the style of the calculator (#405)
* refactor: optimize the style of the calculator

* docs: update changelog
2025-04-22 12:17:40 +08:00
BiggerRain
04903a09cd build: build web components and publish (#404)
* build: build web components and publish

* docs: update notes
2025-04-22 09:35:04 +08:00
ayangweb
44b5f8400e feat: added support for the calculator function (#399)
* feat: added support for the calculator function

* chore: deletion of duplicate files

* refactor: rust implements the conversion logic

* refactor: optimize string handling logic for number to word conversion

* refactor: adjusting styles to improve text overflow

* feat: adding tips

* feat(utils): adjust copy success message according to language settings

* feat(TypeIcon): add support for Calculator icons

* refactor(Search): refactoring context menu logic and component structure
2025-04-21 19:40:46 +08:00
ayangweb
77e6b58381 refactor: show placeholder image when history is empty (#398) 2025-04-21 14:39:06 +08:00
BiggerRain
f6e5e826fd chore: update assistant icon & think mode (#397)
* fix: assistant icon & think model

* docs: update notes

* chore: optimize the code
2025-04-21 12:18:45 +08:00
SteveLauC
886400bcbc fix: correct datasource ID in returned documents (#396)
* fix: correct datasource ID in returned documents
2025-04-21 10:23:55 +08:00
BiggerRain
53258ee834 feat: add support for switching AI assistants (#395)
* feat: add assistant

* build: build warning

* fix: filter http query_args and convert only supported values

* chore: server name truncate

* feat: add support for AI assistant

* feat: add support for AI assistant

---------

Co-authored-by: medcl <m@medcl.net>
2025-04-20 21:27:25 +08:00
Medcl
e8d197fb32 fix: filter http query_args and convert only supported values (#394) 2025-04-19 09:59:24 +08:00
ayangweb
195b6e7af1 refactor: optimization misc issues (#388)
* refactor: optimization problem

* refactor: optimize icon display logic

* refactor: optimized code

* style: remove useless import

* refactor: new shortcut hints for deleting popup boxes

* refactor: remove shortcut scopes from the history list of the standalone window

* refactor: change the key value of the shortcut fixed window from "F" to "P".

* refactor: shortcut to reset a fixed window

* refactor: persistence

* fix: fix shortcut key duplication problem

* style: temporarily annotate unused components

* refactor: remove unused imports

* refactor: change font size

* refactor: refresh to add rotation status

* refactor: show session files
2025-04-18 16:17:05 +08:00
Medcl
6f08d1e934 fix: get attachments in chat sessioins (#392) 2025-04-18 16:14:18 +08:00
SteveLauC
de89ad8d9a fix: app launching on Linux (#390)
* feat: custom open() interface

* refactor: front-end invocation

* refactor: async open()

* refactor: use gtk-launch instead

* style: fmt

---------

Co-authored-by: ayang <473033518@qq.com>
2025-04-18 15:02:45 +08:00
BiggerRain
a5657e61c0 chore: debug the code (#391) 2025-04-18 14:39:27 +08:00
SteveLauC
20e8658da8 fix: linux app search (#389) 2025-04-18 11:32:56 +08:00
BiggerRain
caf9f0238f feat: add error notification (#386)
* feat: add error notification

* feat: add error collection

* chore: error display

* chore: error string

* docs: update notes

* docs: update notes

* build: build error

* chore: errors 5 and link length truncation
2025-04-18 10:36:00 +08:00
ayangweb
f18f94ea6d refactor: replace the app icon after search with an absolute path (#387) 2025-04-18 10:18:28 +08:00
Medcl
bbb517237f refactor: refactoring api error handling (#382)
* refactor: refactoring api error handling

* chore: update release notes

* chore: merge from main
2025-04-17 18:42:28 +08:00
ayangweb
0bf6686494 feat: add keyboard-only operation to history list (#385)
* feat: add keyboard-only operation to history list

* docs: update changelog
2025-04-17 18:41:54 +08:00
BiggerRain
9f04fb1e0f style: search data display (#380)
* style: search date display

* style: adjust style

* style: search detail display

* docs: update notes

* build: build error
2025-04-17 17:34:42 +08:00
ayangweb
542fd5b233 refactor: upgrade tauri-plugin-fs-pro version and optimize code (#383)
* refactor: upgrade `tauri-plugin-fs-pro` version and optimize code

* style: delete useless tunes
2025-04-17 16:15:05 +08:00
SteveLauC
26bf391937 fix: correct app default search path on macOS (#381) 2025-04-17 11:57:14 +08:00
ayangweb
20b653391c chore: lock tauri-plugin-fs-pro version to 2.3.1 (#379) 2025-04-17 10:30:53 +08:00
BiggerRain
a9aab4e4d5 style: search list details display (#378)
* style: search list detail show

* style: search list detail show

* docs: update notes
2025-04-17 09:04:36 +08:00
Medcl
b25f820288 fix: chat history was not show up (#377)
* fix: query chat history

* chore: update release notes

* chore: update parameter check
2025-04-17 07:34:05 +08:00
BiggerRain
a6205eff1b chore: moblie & web display (#376)
* style: adjust style

* chore: moblie & web display
2025-04-16 20:19:12 +08:00
ayangweb
af70639eb3 feat: add application management to the plugin (#374)
* feat: add application management to the plugin

* refactor: add dark color mode support

* docs: update changelog

* style: add a full note
2025-04-16 19:06:31 +08:00
ayangweb
bd5015efeb refactor: add shortcut key hints to data source list (#375)
* refactor: add shortcut key hints to data source list

* refactor: flex layout implementation
2025-04-16 18:29:28 +08:00
BiggerRain
1c59a88a38 style: dark bg color (#373) 2025-04-16 15:53:10 +08:00
ayangweb
8fef0a5d8b style: remove unused code (#372) 2025-04-16 11:55:55 +08:00
ayangweb
4eed4cb1d9 refactor: shortcuts take effect in the popup box when opening the popup box (#371) 2025-04-16 11:46:32 +08:00
rain
eff37d6764 style: adjust moblie height & rounded 2025-04-16 11:45:09 +08:00
BiggerRain
a22024f640 style: modify the style (#370)
* style: modify the style

* style: adjust page style

* style: web style

* docs: update notes
2025-04-16 11:19:23 +08:00
ayangweb
c3bef7e46b refactor: disable system shortcuts (#369) 2025-04-16 10:06:51 +08:00
ayangweb
0703808009 refactor: optimization of search box styles for networked search data sources (#368) 2025-04-16 09:24:46 +08:00
ayangweb
23ae478e47 feat: networked search data sources support search and keyboard-only operation (#367)
* feat: networked search data sources support search and keyboard-only operation

* docs: update changelog
2025-04-15 20:13:42 +08:00
Medcl
6ecb232685 chore: update release notes (#366) 2025-04-15 18:22:00 +08:00
Medcl
e4785f0654 chore: update user profile (#365) 2025-04-15 18:18:02 +08:00
ayangweb
fc2c311624 refactor: unify platform adapter interfaces and optimize code structure (#363)
* refactor: unify platform adapter interfaces and optimize code structure

* style: remove unused comments
2025-04-15 10:50:26 +08:00
BiggerRain
0d15b3b6be chore: adjust web component styles (#362)
* chore: adjust web component styles

* docs: update notes
2025-04-15 08:55:06 +08:00
ayangweb
689631cde2 refactor: optimized history search and renaming (#360)
* refactor: optimized history search and renaming

* docs: update changelogs
2025-04-14 22:09:13 +08:00
SteveLauC
326b1f5bff feat: impl list_app_with_metadata_in() (#361)
* feat: impl list_app_with_metadata_in()

* docs: add link to serde_json::Number::from_u128()

* fix: app search

* chore: more default search paths for macOS
2025-04-14 20:30:25 +08:00
ayangweb
0a7b445661 feat: service list popup box supports keyboard-only operation (#359)
* feat: service list popup box supports keyboard-only operation

* docs: update changelog
2025-04-14 15:01:58 +08:00
ayangweb
62cbb95000 refactor: optimize history deletion (#358) 2025-04-11 17:33:58 +08:00
BiggerRain
2b11d4a2a8 chore: add translate (#357) 2025-04-11 17:11:52 +08:00
ayangweb
2cc3bf55c7 refactor: add autofocus to input boxes and block menu item default events (#356)
* refactor: add autofocus to input boxes and block menu item default events

* refactor: remove the portal attribute

* refactor: removing unnecessary event.preventDefault calls
2025-04-11 17:08:48 +08:00
ayangweb
76880460c5 feat: add a shortcut to open a network search range (#355) 2025-04-11 14:59:37 +08:00
ayangweb
42fb9563a7 refactor: displays the default mode when the app is launched for the first time (#353)
* refactor: displays the default mode when the app is launched for the first time

* refactor: optimize startup mode judgment logic

* style: removing warnings about unused variables and allowing dead code
2025-04-11 14:18:53 +08:00
BiggerRain
e088f5dcbe fix: active shadow setting (#354)
* chore: active shadow setting

* chore: add isTauri

* chore: web build

* docs: update notes
2025-04-11 14:17:42 +08:00
SteveLauC
024dc3155d feat: expose applications::get_default_search_paths (#352) 2025-04-11 11:48:48 +08:00
ayangweb
0948ab1035 refactor: fix chat window style issues (#351) 2025-04-11 10:33:40 +08:00
rain
19e2f5eb4f build: build tauri 2025-04-10 16:25:10 +08:00
BiggerRain
935cdef391 style: add width (#349) 2025-04-10 16:13:35 +08:00
BiggerRain
7e4f4b5303 feat: mobile terminal adaptation about style (#348)
* feat: mobile terminal adaptation

* feat: mobile terminal adaptation

* feat: mobile terminal adaptation

* docs: update notes

* chore: remove log
2025-04-10 16:03:38 +08:00
ayangweb
c053b55759 docs: update changelog (#345) 2025-04-10 09:14:44 +08:00
Medcl
7fa56cfc7d refactor: refactoring login callback, receive access_token from coco-server (#344) 2025-04-10 07:12:17 +08:00
ayangweb
c15fd2ce73 feat: add a border to the main window in Windows 10 (#343)
* feat: add a border to the main window in Windows 10

* refactor: remove unused code

* refactor: add dark themed borders
2025-04-09 17:04:20 +08:00
BiggerRain
6c90f42da0 feat: add font icon (#342)
* feat: add font icon

* docs: update notes

* chore: cleanup the uncessary change
2025-04-08 22:58:53 +08:00
ayangweb
72e5224e39 refactor: replace the history list in the main window (#341) 2025-04-08 21:43:41 +08:00
ayangweb
b602121cd3 refactor: replacing the open method (#340) 2025-04-08 16:27:45 +08:00
ayangweb
211ba463d0 fix: fix apps and articles not opening (#339)
* fix: fix apps and articles not opening

* style: adjusting the order of import statements
2025-04-08 15:48:59 +08:00
ayangweb
b45eb0b91d fix: fixed the problem of not being able to search in secondary directories (#338)
* fix: fixed the problem of not being able to search in secondary directories

* docs: update changelog
2025-04-08 15:35:10 +08:00
BiggerRain
57b2a20c56 fix: load app & web utils (#337) 2025-04-08 15:24:12 +08:00
ayangweb
59622a768b refactor: improvement of internationalization content (#336) 2025-04-07 18:25:22 +08:00
ayangweb
1cace28760 feat: add shortcuts for some icon buttons (#334)
* feat: add shortcuts for some icon buttons

* feat: support for switching the fixed state of the window

* refactor: optimize the issue of page jumping caused by the display of shortcut keys

* feat: deep thinking and networking search add shortcuts

* refactor: changing the default shortcut keys

* refactor: hide the voice input function button

* docs: update changelog
2025-04-07 16:20:13 +08:00
BiggerRain
eb32b03b48 chore: optimizing the code (#335) 2025-04-07 16:09:05 +08:00
BiggerRain
04d00c808d style: compatible with css style (#333)
* style: compatible with css style

* style: compatible with css style
2025-04-07 12:14:35 +08:00
BiggerRain
73a65718ef chore: app css & utils (#332) 2025-04-07 11:37:43 +08:00
BiggerRain
e15baef8f9 refactor: web components (#331)
* refactor: web components

* chore: web component

* chore: web

* chore: web

* docs: update notes
2025-04-07 11:19:09 +08:00
ayangweb
7225635f08 feat: linux support for application search (#330)
* feat: linux support for application search

* docs: update changelog
2025-04-03 15:36:30 +08:00
Medcl
ecc5757af6 chore: update preview (#327) 2025-04-03 09:42:09 +08:00
ayangweb
6a9b1b53b9 refactor: disable outline for all elements (#326)
* refactor: disable outline for all elements

* refactor: modify list item hover background color
2025-04-02 16:01:57 +08:00
ayangweb
a3663703e4 refactor: migration of attachments and transcription functionality to the commands module (#324) 2025-04-02 14:45:39 +08:00
ayangweb
3aed3a0df4 feat: history added search and action menus (#322)
* feat: history added search and action menus

* refactor: refinement of the dark theme

* feat: add renamed input box style

* feat: internalization

* refactor: optimize the bright theme style

* refactor: change dark theme style

* feat: added api for deleting and modifying conversations

* feat: supported search

* feat: support for modifying the title

* feat: support for deleting sessions

* refactor: remove popup internationalization
2025-04-02 14:03:40 +08:00
ayang
569a61841c v0.3.0 2025-03-31 21:45:50 +08:00
ayang
8b2fc07519 docs: update changelog 2025-03-31 21:44:08 +08:00
ayangweb
bf145c8697 style: commenting out unused variables (#320) 2025-03-31 20:57:51 +08:00
ayangweb
0c3606820c docs: update changelog (#319) 2025-03-31 18:36:37 +08:00
ayangweb
3df86fc1c4 refactor: hide voice input and file upload functions (#318) 2025-03-31 18:35:06 +08:00
ayangweb
d01cbe1541 refactor: different platforms support different modifier keys (#317) 2025-03-31 17:17:39 +08:00
ayangweb
89a763dff7 feat: supports keyboard shortcuts with immediate effect (#316)
* feat: supports keyboard shortcuts with immediate effect

* feat: customize mode switching shortcuts

* refactor: remove the shift

* fix: voice input audio input device number anomaly issue

* feat: support for changing the focus state of the input box

* refactor: shortcuts for handling input box focus separately

* feat: upload file support shortcuts

* refactor: the connection timeout is specified with the variable

* refactor: shortcut keys to modify the input box before displaying modifier keys

* docs: update changelog

* style: remove useless import

* refactor: window focus changes modifier key press status to false

* refactor: correcting errors of judgment

* docs: update changelog
2025-03-31 17:07:34 +08:00
Medcl
0c42a51cb5 chore: support icon url parsed by server (#315)
* chore: support icon url parsed by server

* chore: update to support full url based icon
2025-03-30 22:20:15 +08:00
Medcl
f514e5a5c9 chore: support multi websocket connections (#314)
* chore: temp commit

* chore: add WebSocket session ID to chat message API headers

* chore: add param clientId

* feat: add websocket id

* chore: add debug logs

* chore: add log

* chore: add connecting

* chore: remove partialize

* fix: fix to support multi websocket connection

* chore: update release notes

---------

Co-authored-by: rain <15911122312@163.com>
2025-03-30 19:33:49 +08:00
ayangweb
b3aff2b353 refactor: added the voice to text api (#313)
* refactor: added the voice to text api

* refactor: update field name
2025-03-30 19:28:15 +08:00
ayangweb
bcb92bfd49 refactor: hide apps without icon (#312)
* refactor: hide apps without icon

* docs: update changelog
2025-03-28 17:56:58 +08:00
ayangweb
d9dea0ea38 feat: support for uploading files to the server (#310)
* feat: support for uploading files to the server

* feat: field Internationalization

* refactor: encapsulation attachment-related requests

* feat: support for getting a list of attachments that have been uploaded for a session

* feat: the session displays the number and list of uploaded files

* feat: internalization

* feat: wrapping the Checkbox component

* feat: add checkbox

* feat: support for deleting uploaded files

* feat: support for selecting uploaded files

* refactor: optimize the display of file icons

* refactor: hide file uploads when there is no sessionId
2025-03-28 13:50:14 +08:00
BiggerRain
d2eed4a1c4 refactor: refactor invoke related code (#309)
* refactor: refactor invoke related code

* refactor: refactor invoke related code

* docs: update release notes
2025-03-25 20:57:46 +08:00
ayangweb
c7e547b5fa refactor: encapsulates show and hide methods (#308)
* refactor: encapsulates show and hide methods

* style: remove comments
2025-03-24 17:19:19 +08:00
ayangweb
eadd0988ba chore: eliminate all warnings for rust (#307) 2025-03-24 14:55:35 +08:00
ayangweb
78bc83f38a refactor: all commands methods have been changed to asynchronous (#306) 2025-03-24 14:39:08 +08:00
ayangweb
84d9c6cdf0 refactor: hide voice input when no radio device is available (#305)
* refactor: hide voice input when no radio device is available

* style: delete Printing
2025-03-24 12:00:42 +08:00
BiggerRain
0769545a92 chore: remove lazy (#304) 2025-03-24 12:00:11 +08:00
ayangweb
118eaa55e3 feat: voice input support for search and chat (#302)
* feat: voice input support for search and chat

* chore: add mic-recorder plugin

* refactor: check microphone permission before recording

* feat: realize sound wave effects

* chore: remove mic-recorder plugin
2025-03-24 09:17:09 +08:00
BiggerRain
ef1304ce5e feat: add web pages (#277)
* feat: add web pages

* feat: add web page

* refactor: search page

* feat: add tsup build web componet

* chore: update timeout time

* build: build web page

* build: build search chat

* chore: add web page

* docs: update release note
2025-03-17 16:24:18 +08:00
Medcl
51d3a9d090 chore: remove dmg-background.png (#301) 2025-03-17 15:24:42 +08:00
ayangweb
7d0eced55a refactor: resolving code conflicts (#300) 2025-03-17 09:29:34 +08:00
ayangweb
e81c5bbb6e feat: advanced settings content improvement (#281)
* feat: advanced settings content improvement

* feat: support for switching to the default mode

* refactor: shortcut keys support only one letter

* refactor: fix key reporting errors

* feat: listen for changes to `ShortcutsStore`

* feat: add configuration items for modifier keys

* feat: new connection settings configuration item

* refactor: replacing the connection timeout icon

* refactor: optimized the style of the input box

* refactor: update Icons

* refactor: defaults to last chat
2025-03-17 09:19:59 +08:00
BiggerRain
bfc7b488ad fix: store data is not shared among multiple windows (#298)
* fix: store data is not shared among multiple windows

* docs: update release notes
2025-03-14 18:41:48 +08:00
Hardy
249cc2eae4 chore: add settings to output docs json (#297)
Co-authored-by: hardy <luohf@infinilabs.com>
2025-03-14 18:37:02 +08:00
BiggerRain
388dac6452 chore: chat input border & clear input (#295)
* chore: chat input border & clear input

* docs: update release notes
2025-03-14 17:19:39 +08:00
ayangweb
dc8d1b5054 refactor: hide voice input buttons (#294) 2025-03-14 17:07:46 +08:00
ayangweb
046c3dda82 chore: update release notes (#293) 2025-03-14 16:27:51 +08:00
medcl
60ce678e3e v0.2.1 2025-03-14 16:06:08 +08:00
Medcl
8d79b9ba1a chore: update release notes (#290) 2025-03-14 15:49:24 +08:00
BiggerRain
969126ed89 chore: websocket timeout increased to 2 minutes (#289)
* chore: websocket timeout increased to 2 minutes

* docs: update release notes
2025-03-14 11:14:03 +08:00
ayangweb
e2df2b583a refactor: optimize the outline of the button (#288) 2025-03-14 11:07:19 +08:00
BiggerRain
9d948d4fc6 chore: remove selected function (#286)
* chore: remove selected function

* docs: update release notes
2025-03-14 10:50:51 +08:00
ayangweb
81c770ba7e refactor: optimize voice input (#285)
* refactor: optimize voice input

* refactor: `useState` instead of `useReactive`
2025-03-14 10:49:44 +08:00
BiggerRain
c9e9a72a0e chore: chat window min width & remove input bg (#284)
* chore: chat window min width & remove input bg

* docs: update release notes

* chore: remove error
2025-03-13 14:41:41 +08:00
SteveLauC
96e6aae30b ci: remove unneeded rust-toolchain action (#283) 2025-03-12 18:06:36 +08:00
BiggerRain
d319f5ebc7 fix: the chat scrolling and chat rendering (#282)
* fix: the chat scrolling and chat rendering

* docs: update release notes
2025-03-12 16:50:35 +08:00
BiggerRain
04ff358dc7 fix: chat end type (#280)
* fix: chat end type

* docs: update release notes
2025-03-12 14:24:24 +08:00
Medcl
22872ab02f fix: incorrect version type (#279)
* fix: incorrect version type

* chore: update release notes
2025-03-12 13:28:43 +08:00
ayangweb
fcfd21be45 refactor: disable manual input during voice input (#278) 2025-03-12 10:46:16 +08:00
ayangweb
0044e9a536 feat: chat supports voice input (#276)
* feat: chat supports voice input

* refactor: hide window out of focus

* feat: search supports voice input
2025-03-11 16:36:51 +08:00
BiggerRain
44a3ea3868 fix: add history reload for coco chat (#275) 2025-03-11 15:48:45 +08:00
BiggerRain
b444dc35ae refactor: chat components (#273)
* refactor: chat components

* refactor: chat components

* docs: update release notes

* docs: update release notes

* chore: history reload
2025-03-11 11:02:30 +08:00
ayangweb
8c9ccef218 feat: support for automatic app updates (#274)
* feat: support for automatic app updates

* refactor: add force update instructions

* refactor: optimize version update alerts

* chore: updating configuration files
2025-03-11 10:36:42 +08:00
ayangweb
a3bc997efe refactor: window not hidden after copying (#272) 2025-03-10 14:52:47 +08:00
Medcl
910841013f fix: fusion search should excluded disabled servers (#271) 2025-03-10 12:08:39 +08:00
361 changed files with 37724 additions and 11068 deletions

4
.env
View File

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

View File

@@ -0,0 +1,18 @@
name: Enforce no dependency pizza-engine
on:
pull_request:
jobs:
main:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name:
working-directory: ./src-tauri
run: |
# if cargo remove pizza-engine succeeds, then it is in our dependency list, fail the CI pipeline.
if cargo remove pizza-engine; then exit 1; fi

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

@@ -9,10 +9,16 @@ on:
jobs:
create-release:
runs-on: ubuntu-latest
outputs:
APP_VERSION: ${{ steps.get-version.outputs.APP_VERSION }}
RELEASE_BODY: ${{ steps.get-changelog.outputs.RELEASE_BODY }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set output
id: vars
run: echo "tag=${GITHUB_REF#refs/*/}" >> $GITHUB_OUTPUT
@@ -22,11 +28,28 @@ jobs:
with:
node-version: 20
- name: Get build version
shell: bash
id: get-version
run: |
PACKAGE_VERSION=$(jq -r '.version' package.json)
CARGO_VERSION=$(grep -m 1 '^version =' src-tauri/Cargo.toml | sed -E 's/.*"([^"]+)".*/\1/')
if [ "$PACKAGE_VERSION" != "$CARGO_VERSION" ]; then
echo "::error::Version mismatch!"
else
echo "Version match: $PACKAGE_VERSION"
fi
echo "APP_VERSION=$PACKAGE_VERSION" >> $GITHUB_OUTPUT
- name: Generate changelog
id: create_release
run: npx changelogithub --draft --name ${{ steps.vars.outputs.tag }}
id: get-changelog
run: |
CHANGELOG_BODY=$(npx changelogithub --draft --name ${{ steps.vars.outputs.tag }})
echo "RELEASE_BODY<<EOF" >> $GITHUB_OUTPUT
echo "$CHANGELOG_BODY" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
env:
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
build-app:
needs: create-release
@@ -50,11 +73,25 @@ jobs:
- platform: "ubuntu-22.04"
target: "x86_64-unknown-linux-gnu"
- platform: "ubuntu-22.04-arm"
target: "aarch64-unknown-linux-gnu"
env:
APP_VERSION: ${{ needs.create-release.outputs.APP_VERSION }}
runs-on: ${{ matrix.platform }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Checkout dependency repository
uses: actions/checkout@v4
with:
repository: 'infinilabs/pizza'
ssh-key: ${{ secrets.SSH_PRIVATE_KEY }}
submodules: recursive
ref: main
path: pizza
- name: Setup node
uses: actions/setup-node@v4
with:
@@ -63,17 +100,31 @@ jobs:
with:
version: latest
- name: Install rust target
run: rustup target add ${{ matrix.target }}
- name: Install dependencies (ubuntu only)
if: matrix.platform == 'ubuntu-22.04'
if: startsWith(matrix.platform, 'ubuntu-22.04')
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf xdg-utils
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
- name: Add Rust build target
working-directory: src-tauri
shell: bash
run: |
rustup target add ${{ matrix.target }} || true
- name: Add pizza engine as a dependency
working-directory: src-tauri
shell: bash
run: |
BUILD_ARGS="--target ${{ matrix.target }}"
if [[ "${{matrix.target }}" != "i686-pc-windows-msvc" ]]; then
echo "Adding pizza engine as a dependency for ${{matrix.platform }}-${{matrix.target }}"
( cargo add --path ../pizza/lib/engine --features query_string_parser,persistence )
BUILD_ARGS+=" --features use_pizza_engine"
else
echo "Skipping pizza engine dependency for ${{matrix.platform }}-${{matrix.target }}"
fi
echo "BUILD_ARGS=${BUILD_ARGS}" >> $GITHUB_ENV
- name: Rust cache
uses: swatinem/rust-cache@v2
@@ -88,8 +139,8 @@ jobs:
- name: Install app dependencies and build web
run: pnpm install --frozen-lockfile
- name: Build the app
- name: Build the coco at ${{ matrix.platform}} for ${{ matrix.target }} @ ${{ env.APP_VERSION }}
uses: tauri-apps/tauri-action@v0
env:
CI: false
@@ -105,8 +156,8 @@ jobs:
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
with:
tagName: ${{ github.ref_name }}
releaseName: Coco ${{ needs.create-release.outputs.APP_VERSION }}
releaseBody: ""
releaseName: Coco ${{ env.APP_VERSION }}
releaseBody: "${{ needs.create-release.outputs.RELEASE_BODY }}"
releaseDraft: true
prerelease: false
args: --target ${{ matrix.target }}
args: ${{ env.BUILD_ARGS }}

61
.github/workflows/rust_code_check.yml vendored Normal file
View File

@@ -0,0 +1,61 @@
name: Rust Code Check
on:
pull_request:
# Only run it when Rust code changes
paths:
- 'src-tauri/**'
jobs:
check:
strategy:
matrix:
platform: [ubuntu-latest, windows-latest, macos-latest]
runs-on: ${{ matrix.platform }}
steps:
- uses: actions/checkout@v4
- name: Checkout dependency (pizza-engine) repository
uses: actions/checkout@v4
with:
repository: 'infinilabs/pizza'
ssh-key: ${{ secrets.SSH_PRIVATE_KEY }}
submodules: recursive
ref: main
path: pizza
- name: Install dependencies (ubuntu only)
if: startsWith(matrix.platform, 'ubuntu-latest')
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf xdg-utils
- name: Add pizza engine as a dependency
working-directory: src-tauri
shell: bash
run: cargo add --path ../pizza/lib/engine --features query_string_parser,persistence
- name: Format check
working-directory: src-tauri
shell: bash
run: |
rustup component add rustfmt
cargo fmt --all --check
- name: Check compilation (Without Pizza engine enabled)
working-directory: ./src-tauri
run: cargo check
- name: Check compilation (With Pizza engine enabled)
working-directory: ./src-tauri
run: cargo check --features use_pizza_engine
- name: Run tests (Without Pizza engine)
working-directory: ./src-tauri
run: cargo test
- name: Run tests (With Pizza engine)
working-directory: ./src-tauri
run: cargo test --features use_pizza_engine

3
.gitignore vendored
View File

@@ -11,6 +11,8 @@ node_modules
dist
dist-ssr
*.local
out
src/components/web
# Editor directories and files
# .vscode/*
@@ -23,3 +25,4 @@ dist-ssr
*.njsproj
*.sln
*.sw?
.env

27
.vscode/settings.json vendored
View File

@@ -4,34 +4,55 @@
"autolaunch",
"Avenir",
"callout",
"changelogithub",
"clsx",
"codegen",
"dataurl",
"deeplink",
"deepthink",
"dtolnay",
"dyld",
"elif",
"errmsg",
"fullscreen",
"fulltext",
"headlessui",
"Icdbb",
"icns",
"iconfont",
"INFINI",
"infinilabs",
"inputbox",
"katex",
"khtml",
"languagedetector",
"libappindicator",
"librsvg",
"libwebkit",
"localstorage",
"lucide",
"maximizable",
"mdast",
"meval",
"Minimizable",
"msvc",
"nord",
"nowrap",
"nspanel",
"nsstring",
"overscan",
"partialize",
"patchelf",
"Quicklink",
"Raycast",
"rehype",
"reqwest",
"rerank",
"rgba",
"rustup",
"screenshotable",
"serde",
"swatinem",
"tailwindcss",
"tauri",
"thiserror",
@@ -41,11 +62,14 @@
"traptitech",
"unlisten",
"unlistener",
"unlisteners",
"unminimize",
"uuidv",
"VITE",
"walkdir",
"wavesurfer",
"webviews",
"xzvf",
"yuque",
"zustand"
],
@@ -59,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

@@ -76,3 +76,10 @@ clean-rebuild:
@echo "Cleaning up and rebuilding..."
rm -rf node_modules
$(MAKE) dev-build
add-dep-pizza-engine:
cd src-tauri && cargo add --git ssh://git@github.com/infinilabs/pizza.git pizza-engine --features query_string_parser,persistence
dev-build-with-pizza: add-dep-pizza-engine
@echo "Starting desktop development with Pizza Engine pulled in..."
RUST_BACKTRACE=1 pnpm tauri dev --features use_pizza_engine

View File

@@ -1,7 +1,15 @@
# Coco AI - Connect & Collaborate
<div align="center">
**Tagline**: _"Coco AI - search, connect, collaborate all in one place."_
Visit our website: [https://coco.rs](https://coco.rs)
[![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) [![Tauri 2.0](https://img.shields.io/badge/Tauri-2.0-blue)](https://tauri.app/) [![React](https://img.shields.io/badge/React-18-blue)](https://react.dev/) [![TypeScript](https://img.shields.io/badge/TypeScript-5-blue)](https://www.typescriptlang.org/) [![Rust](https://img.shields.io/badge/Rust-latest-orange)](https://www.rust-lang.org/) [![Node](https://img.shields.io/badge/Node-%3E%3D18.12-green)](https://nodejs.org/) [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](https://github.com/infinilabs/coco-app/pulls) [![Version](https://img.shields.io/github/v/release/infinilabs/coco-app)](https://github.com/infinilabs/coco-app/releases) [![Build Status](https://img.shields.io/github/actions/workflow/status/infinilabs/coco-app/ci.yml)](https://github.com/infinilabs/coco-app/actions) [![Discord](https://img.shields.io/discord/1122384609359966313)](https://discord.com/invite/4tKTMkkvVX)
</div>
Coco AI is a unified search platform that connects all your enterprise applications and data—Google Workspace, Dropbox,
Confluent Wiki, GitHub, and more—into a single, powerful search interface. This repository contains the **Coco App**,
built for both **desktop and mobile**. The app allows users to search and interact with their enterprise data across
@@ -12,16 +20,15 @@ and internal resources. Coco enhances collaboration by making information instan
insights based on your enterprise's specific data.
> **Note**: Backend services, including data indexing and search functionality, are handled in a
> separate [repository](https://github.com/infinilabs/coco-server).
separate [repository](https://github.com/infinilabs/coco-server).
## Vision
![Coco AI](./docs/static/img/coco-preview.gif)
At Coco AI, we aim to streamline workplace collaboration by centralizing access to enterprise data. The Coco
App
provides a seamless, cross-platform experience, enabling teams to easily search, connect, and collaborate within their
workspace.
## 🚀 Vision
## Use Cases
At Coco AI, we aim to streamline workplace collaboration by centralizing access to enterprise data. The Coco App provides a seamless, cross-platform experience, enabling teams to easily search, connect, and collaborate within their workspace.
## 💡 Use Cases
- **Unified Search Across Platforms**: Coco integrates with all your enterprise apps, letting you search documents,
conversations, and files across Google Workspace, Dropbox, GitHub, etc.
@@ -32,37 +39,75 @@ workspace.
- **Simplified Data Access**: By removing the friction between various tools, Coco enhances your workflow and increases
productivity.
## Getting Started
## ✨ Key Features
### Initial Setup
- 🔍 **Unified Search**: One-stop enterprise search with multi-platform integration
- Supports major collaboration platforms: Google Workspace, Dropbox, Confluence Wiki, GitHub, etc.
- Real-time search across documents, conversations, and files
- Smart search intent understanding with relevance ranking
- Cross-platform data correlation and context display
- 🤖 **AI-Powered Chat**: Team-specific ChatGPT-like assistant trained on your enterprise data
- 🌐 **Cross-Platform**: Available for Windows, macOS, Linux and Web
- 🔒 **Security-First**: Support for private deployment and data sovereignty
-**High Performance**: Built with Rust and Tauri 2.0
- 🎨 **Modern UI**: Sleek interface designed for productivity
**This version of pnpm requires at least Node.js v18.12**
## 🛠️ Technology Stack
To set up the Coco App for development:
- **Frontend**: React + TypeScript
- **Desktop Framework**: Tauri 2.0
- **Styling**: Tailwind CSS
- **State Management**: Zustand
- **Build Tool**: Vite
## 🚀 Getting Started
### Prerequisites
- Node.js >= 18.12
- Rust (latest stable)
- pnpm (package manager)
### Development Setup
```bash
cd coco-app
# Install pnpm
npm install -g pnpm
# Install dependencies
pnpm install
# Start development server
pnpm tauri dev
```
#### Desktop Development:
To start desktop development, run:
### Production Build
```bash
pnpm tauri dev
pnpm tauri build
```
## Documentation
## 📚 Documentation
For full documentation on Coco AI, please visit the [Coco AI Documentation](https://docs.infinilabs.com/coco-app/main/).
- [Coco App Documentation](https://docs.infinilabs.com/coco-app/main/)
- [Coco Server Documentation](https://docs.infinilabs.com/coco-server/main/)
- [DeepWiki Coco App](https://deepwiki.com/infinilabs/coco-app)
- [DeepWiki Coco Server](https://deepwiki.com/infinilabs/coco-server)
- [Tauri Documentation](https://tauri.app/)
## License
## Contributors
Coco AI is an open-source project licensed under
the [MIT License](https://github.com/infinilabs/coco-app/blob/main/LICENSE).
<a href="https://github.com/infinilabs/coco-app/graphs/contributors">
<img src="https://contrib.rocks/image?repo=infinilabs/coco-app" />
</a>
This means that you can freely use, modify, and
distribute the software for both personal and commercial purposes, including hosting it on your own servers.
## 📄 License
Coco AI is an open-source project licensed under the [MIT License](LICENSE). You can freely use, modify, and
distribute the software for both personal and commercial purposes, including hosting it on your own servers.
---
<div align="center">
Built with ❤️ by <a href="https://infinilabs.com">INFINI Labs</a>
</div>

View File

@@ -7,6 +7,12 @@ theme: book
disablePathToLower: true
enableGitInfo: false
outputs:
home:
- HTML
- RSS
- JSON
# Needed for mermaid/katex shortcodes
markup:
goldmark:

View File

@@ -7,8 +7,7 @@ type: docs
Coco AI is a fully open-source, cross-platform unified search and productivity tool that connects and searches across various data sources, including applications, files, Google Drive, Notion, Yuque, Hugo, and more, both local and cloud-based. By integrating with large models like DeepSeek, Coco AI enables intelligent personal knowledge management, emphasizing privacy and supporting private deployment, helping users quickly and intelligently access their information.
{{% load-img "/img/screenshot/fusion-search-across-datasources.png" "" %}}
{{% load-img "/img/screenshot/coco-chat.png" "" %}}
{{% load-img "/img/coco-preview.gif" "" %}}
For more details on Coco Server, visit: [https://docs.infinilabs.com/coco-app/](https://docs.infinilabs.com/coco-app/).

View File

@@ -7,8 +7,7 @@ type: docs
Coco AI is a fully open-source, cross-platform unified search and productivity tool that connects and searches across various data sources, including applications, files, Google Drive, Notion, Yuque, Hugo, and more, both local and cloud-based. By integrating with large models like DeepSeek, Coco AI enables intelligent personal knowledge management, emphasizing privacy and supporting private deployment, helping users quickly and intelligently access their information.
{{% load-img "/img/screenshot/fusion-search-across-datasources.png" "" %}}
{{% load-img "/img/screenshot/coco-chat.png" "" %}}
{{% load-img "/img/coco-preview.gif" "" %}}
For more details on Coco Server, visit: [https://docs.infinilabs.com/coco-app/](https://docs.infinilabs.com/coco-app/).

View File

@@ -1,21 +1,35 @@
---
weight: 10
title: "Mac OS"
title: "macOS"
asciinema: true
---
# Mac OS
# macOS
## Download Coco AI
Goto [https://coco.rs/](https://coco.rs/)
Go to [coco.rs](https://coco.rs/) and download the package of your architecture:
{{% load-img "/img/download-mac-app.png" "" %}}
{{% load-img "/img/macos/mac-download-app.png" "" %}}
It should be placed in your "Downloads" folder:
{{% load-img "/img/macos/mac-zip-file.png" "" %}}
## Unzip DMG file
{{% load-img "/img/unzip-dmg-file.png" "" %}}
Unzip the file:
{{% load-img "/img/macos/mac-unzip-zip-file.png" "" %}}
You will get a `dmg` file:
{{% load-img "/img/macos/mac-dmg.png" "" %}}
## Drag to Application Folder
{{% load-img "/img/drag-to-application-folder.png" "" %}}
Double click the `dmg` file, a window will pop up. Then drag the "Coco-AI" app to
your "Applications" folder:
{{% load-img "/img/macos/drag-to-app-folder.png" "" %}}

View File

@@ -0,0 +1,40 @@
---
weight: 10
title: "Ubuntu"
asciinema: true
---
# Ubuntu
> NOTE: Coco app only works fully under [X11][x11_protocol].
>
> Don't know if you running X11 or not? take a look at this [question][if_x11]!
[x11_protocol]: https://en.wikipedia.org/wiki/X_Window_System
[if_x11]: https://unix.stackexchange.com/q/202891/498440
## Go to the download page
Download page: [link](https://coco.rs/#install)
## Download the package
Download the package of your architecture, it should be put in your `Downloads` directory
and look like this:
```sh
$ cd ~/Downloads
$ ls
Coco-AI-x.y.z-bbbb-deb-linux-amd64.zip
# or Coco-AI-x.y.z-bbbb-deb-linux-arm64.zip depending on your architecture
```
## Install it
Unzip and install it
```
$ unzip Coco-AI-x.y.z-bbbb-deb-linux-amd64.zip
$ sudo dpkg -i Coco-AI-x.y.z-bbbb-deb-linux-amd64.deb
```

View File

@@ -5,18 +5,345 @@ title: "Release Notes"
# Release Notes
Information about release notes of Coco Server is provided here.
Information about release notes of Coco App is provided here.
## Latest (In development)
### ❌ Breaking changes
### 🚀 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
### 🚀 Features
### 🐛 Bug fix
- fix: correct enter key behavior #828
### ✈️ Improvements
- chore: web component add notification component #825
- refactor: collection behavior defaults to `MoveToActiveSpace`, and only use `CanJoinAllSpaces` when window is pinned #829
## 0.7.0 (2025-07-25)
### ❌ Breaking changes
### 🚀 Features
- feat: file search using spotlight #705
- feat: voice input support in both search and chat modes #732
- feat: text to speech now powered by LLM #750
- feat: file search for Windows #762
### 🐛 Bug fix
- fix(file search): apply filters before from/size parameters #741
- fix(file search): searching by name&content does not search file name #743
- fix: prevent window from hiding when moved on Windows #748
- fix: unregister ext hotkey when it gets deleted #770
- fix: indexing apps does not respect search scope config #773
- fix: restore missing category titles on subpages #772
- fix: correct incorrect assistant display when quick ai access #779
- fix: resolved minor issues with voice playback #780
- fix: fixed incorrect taskbar icon display on linux #783
- fix: fix data inconsistency issue on secondary pages #784
- fix: incorrect status when installing extension #789
- fix: increase read_timeout for HTTP streaming stability #798
- fix: enter key problem #794
- fix: fix selection issue after renaming #800
- fix: fix shortcut issue in windows context menu #804
- fix: panic caused by "state() called before manage()" #806
- fix: fix multiline input issue #808
- fix: fix ctrl+k not working #815
- fix: fix update window config sync #818
- fix: fix enter key on subpages #819
- fix: panic on Ubuntu (GNOME) when opening apps #821
### ✈️ Improvements
- refactor: prioritize stat(2) when checking if a file is dir #737
- refactor: change File Search ext type to extension #738
- refactor: create chat & send chat api #739
- chore: icon support for more file types #740
- chore: replace meval-rs with our fork to clear dep warning #745
- refactor: adjusted assistant, datasource, mcp_server interface parameters #746
- refactor: adjust extension code hierarchy #747
- chore: bump dep applications-rs #751
- chore: rename QuickLink/quick_link to Quicklink/quicklink #752
- chore: assistant params & styles #753
- chore: make optional fields optional #758
- chore: search-chat components add formatUrl & think data & icons url #765
- chore: Coco app http request headers #744
- refactor: do status code check before deserializing response #767
- style: splash adapts to the width of mobile phones #768
- chore: search-chat add language and formatUrl parameters #775
- chore: not request the interface if not logged in #795
- refactor: clean up unsupported characters from query string in Win Search #802
- chore: display backtrace in panic log #805
## 0.6.0 (2025-06-29)
### ❌ Breaking changes
### 🚀 Features
- feat: support `Tab` and `Enter` for delete dialog buttons #700
- feat: add check for updates #701
- feat: impl extension store #699
- feat: support back navigation via delete key #717
### 🐛 Bug fix
- fix: quick ai state synchronous #693
- fix: toggle extension should register/unregister hotkey #691
- fix: take coco server back on refresh #696
- fix: some input fields couldnt accept spaces #709
- fix: context menu search not working #713
- fix: open extension store display #724
### ✈️ Improvements
- refactor: use author/ext_id as extension unique identifier #643
- refactor: refactoring search api #679
- chore: continue to chat page display #690
- chore: improve server list selection with enter key #692
- chore: add message for latest version check #703
- chore: log command execution results #718
- chore: adjust styles and add button reindex #719
## 0.5.0 (2025-06-13)
### ❌ Breaking changes
### 🚀 Features
- feat: check or enter to close the list of assistants #469
- feat: add dimness settings for pinned window #470
- feat: supports Shift + Enter input box line feeds #472
- feat: support for snapshot version updates #480
- feat: history list add put away button #482
- feat: the chat input box supports multi-line input #490
- feat: add `~/Applications` to the search path #493
- feat: the chat content has added a button to return to the bottom #495
- feat: the search input box supports multi-line input #501
- feat: websocket support self-signed TLS #504
- feat: add option to allow self-signed certificates #509
- feat: add AI summary component #518
- feat: dynamic log level via env var COCO_LOG #535
- feat: add quick AI access to search mode #556
- feat: rerank search results #561
- feat: ai overview support is enabled with shortcut #597
- feat: add key monitoring during reset #615
- feat: calculator extension add description #623
- feat: support right-click actions after text selection #624
- feat: add ai overview minimum number of search results configuration #625
- feat: add internationalized translations of AI-related extensions #632
- feat: context menu support for secondary pages #680
### 🐛 Bug fix
- fix: solve the problem of modifying the assistant in the chat #476
- fix: several issues around search #502
- fix: fixed the newly created session has no title when it is deleted #511
- fix: loading chat history for potential empty attachments
- fix: datasource & MCP list synchronization update #521
- fix: app icon & category icon #529
- fix: show only enabled datasource & MCP list
- fix: server image loading failure #534
- fix: panic when fetching app metadata on Windows #538
- fix: service switching error #539
- fix: switch server assistant and session unchanged #540
- fix: history list height #550
- fix: secondary page cannot be searched #551
- fix: the scroll button is not displayed by default #552
- fix: suggestion list position #553
- fix: independent chat window has no data #554
- fix: resolved navigation error on continue chat action #558
- fix: make extension search source respect parameter datasource #576
- fix: fixed issue with incorrect login status #600
- fix: new chat assistant id not found #603
- fix: resolve regex error on older macOS versions #605
- fix: fix chat log update and sorting issues #612
- fix: resolved an issue where number keys were not working on the web #616
- fix: do not panic when the datasource specified does not exist #618
- fix: fixed modifier keys not working with continue chat #619
- fix: invalid DSL error if input contains multiple lines #620
- fix: fix ai overview hidden height before message #622
- fix: tab key hides window in chat mode #641
- fix: arrow keys still navigated search when menu opened with Cmd+K #642
- fix: input lost when reopening dialog after search #644
- fix: web page unmount event #645
- fix: fix the problem of local path not opening #650
- fix: number keys not following settings #661
- fix: fix problem with up and down key indexing #676
- fix: arrow inserting escape sequences #683
### ✈️ Improvements
- chore: adjust list error message #475
- chore: refine wording on search failure
- choresearch and MCP show hidden logic #494
- chore: greetings show hidden logic #496
- refactor: fetch app list in settings in real time #498
- chore: UpdateApp component loading location #499
- chore: add clear monitoring & cache calculation to optimize performance #500
- refactor: optimizing the code #505
- refactor: optimized the modification operation of the numeric input box #508
- style: modify the style of the search input box #513
- style: chat input icons show #515
- refactor: refactoring icon component #514
- refactor: optimizing list styles in markdown content #520
- feat: add a component for text reading aloud #522
- style: history component styles #528
- style: search error styles #533
- chore: skip register server that not logged in #536
- refactor: service info related components #537
- chore: chat content can be copied #539
- refactor: refactoring search error #541
- chore: add assistant count #542
- chore: add global login judgment #544
- chore: mark server offline on user logout #546
- chore: logout update server profile #549
- chore: assistant keyboard events and mouse events #559
- chore: web component start page config #560
- chore: assistant chat placeholder & refactor input box components #566
- refactor: input box related components #568
- chore: mark unavailable server to offline on refresh info #569
- chore: only show available servers in chat #570
- refactor: search result related components #571
- chore: initialize current assistant from history #606
- chore: add onContextMenu event #629
- chore: more logs for the setup process #634
- chore: copy supports http protocol #639
- refactor: use author/ext_id as extension unique identifier #643
- chore: add special character filtering #668
## 0.4.0 (2025-04-27)
### Breaking changes
### Features
- feat: history support for searching, renaming and deleting #322
- feat: linux support for application search #330
- feat: add shortcuts to most icon buttons #334
- feat: add font icon for search list #342
- feat: add a border to the main window in Windows 10 #343
- feat: mobile terminal adaptation about style #348
- feat: service list popup box supports keyboard-only operation #359
- feat: networked search data sources support search and keyboard-only operation #367
- feat: add application management to the plugin #374
- feat: add keyboard-only operation to history list #385
- feat: add error notification #386
- feat: add support for AI assistant #394
- feat: add support for calculator function #399
- feat: auto selects the first item after searching #411
- feat: web components assistant #422
- feat: right-click menu support for search #423
- feat: add chat mode launch page #424
- feat: add MCP & call LLM tools #430
- feat: ai assistant supports search and paging #431
- feat: data sources support displaying customized icons #432
- feat: add shortcut key conflict hint and reset function #442
- feat: updated to include error message #465
- feat: support third party extensions #572
- feat: support ai overview #572
### Bug fix
- fix: fixed the problem of not being able to search in secondary directories #338
- fix: active shadow setting #354
- fix: chat history was not show up #377
- fix: get attachments in chat sessions
- fix: filter http query_args and convert only supported values
- fixfixed several search & chat bugs #412
- fix: fixed carriage return problem with chinese input method #464
### Improvements
- refactor: web components #331
- refactor: refactoring login callback, receive access_token from coco-server
- chore: adjust web component styles #362
- style: modify the style #370
- style: search list details display #378
- refactor: refactoring api error handling #382
- chore: update assistant icon & think mode #397
- build: build web components and publish #404
## 0.3.0 (2025-03-31)
### Breaking changes
### Features
- feat: add web pages components #277
- feat: support for customizing some of the preset shortcuts #316
- feat: support multi websocket connections #314
- feat: add support for embeddable web widget #277
### Bug fix
### Improvements
- refactor: refactor invoke related code #309
- refactor: hide apps without icon #312
## 0.2.1 (2025-03-14)
### Features
- support for automatic in-app updates #274
### Breaking changes
### Bug fix
- Fix the issue that the fusion search include disabled servers
- Fix incorrect version type: should be string instead of u32
- Fix the chat end judgment type #280
- Fix the chat scrolling and chat rendering #282
- Fix: store data is not shared among multiple windows #298
### Improvements
- Refactor: chat components #273
- Feat: add endpoint display #282
- Chore: chat window min width & remove input bg #284
- Chore: remove selected function & add hide_coco #286
- Chore: websocket timeout increased to 2 minutes #289
- Chore: remove chat input border & clear input #295
## 0.2.0 (2025-03-07)
### Features
@@ -25,7 +352,7 @@ Information about release notes of Coco Server is provided here.
- Add api to disable or enable server #185
- Networked search supports selection of data sources #209
- Add deepthink and knowledge search options to RAG based chat
- Support i18n, add Chinese language support
- Support i18n, add Chinese language support
- Support Windows platform
- etc.
@@ -54,7 +381,6 @@ Information about release notes of Coco Server is provided here.
- Allow to switch servers in the settings page
- etc.
## 0.1.0 (2025-02-16)
### Features

BIN
docs/static/img/coco-preview.gif vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

BIN
docs/static/img/macos/mac-dmg.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 586 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 299 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 650 KiB

BIN
docs/static/img/macos/mac-zip-file.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 441 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

View File

@@ -7,7 +7,7 @@
<title>Coco</title>
</head>
<body>
<body class="coco-container">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>

View File

@@ -1,11 +1,16 @@
{
"name": "coco",
"private": true,
"version": "0.2.0",
"version": "0.7.1",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"build:web": "cross-env BUILD_TARGET=web tsc && cross-env BUILD_TARGET=web tsup --format esm",
"publish:web": "cd out/search-chat && npm publish",
"publish:web:beta": "cd dist/search-chat && npm publish --tag beta",
"publish:web:alpha": "cd dist/search-chat && npm publish --tag alpha",
"publish:web:rc": "cd dist/search-chat && npm publish --tag rc",
"preview": "vite preview",
"tauri": "tauri",
"release": "release-it",
@@ -13,33 +18,38 @@
"release-beta": "release-it --preRelease=beta --preReleaseBase=1"
},
"dependencies": {
"@headlessui/react": "^2.2.0",
"@tauri-apps/api": "^2.3.0",
"@headlessui/react": "^2.2.2",
"@tauri-apps/api": "^2.5.0",
"@tauri-apps/plugin-autostart": "~2.2.0",
"@tauri-apps/plugin-deep-link": "^2.2.0",
"@tauri-apps/plugin-dialog": "^2.2.0",
"@tauri-apps/plugin-deep-link": "^2.2.1",
"@tauri-apps/plugin-dialog": "^2.2.1",
"@tauri-apps/plugin-global-shortcut": "~2.0.0",
"@tauri-apps/plugin-http": "~2.0.2",
"@tauri-apps/plugin-os": "^2.2.0",
"@tauri-apps/plugin-process": "^2.2.0",
"@tauri-apps/plugin-shell": "^2.2.0",
"@tauri-apps/plugin-updater": "^2.5.1",
"@tauri-apps/plugin-websocket": "~2.3.0",
"@tauri-apps/plugin-log": "~2.4.0",
"@tauri-apps/plugin-opener": "^2.2.7",
"@tauri-apps/plugin-os": "^2.2.1",
"@tauri-apps/plugin-process": "^2.2.1",
"@tauri-apps/plugin-shell": "^2.2.1",
"@tauri-apps/plugin-updater": "github:infinilabs/tauri-plugin-updater#v2",
"@tauri-apps/plugin-window": "2.0.0-alpha.1",
"@wavesurfer/react": "^1.0.11",
"ahooks": "^3.8.4",
"axios": "^1.9.0",
"clsx": "^2.1.1",
"dotenv": "^16.4.7",
"dayjs": "^1.11.13",
"dotenv": "^16.5.0",
"filesize": "^10.1.6",
"i18next": "^23.16.8",
"i18next-browser-languagedetector": "^8.0.4",
"i18next-browser-languagedetector": "^8.1.0",
"lodash-es": "^4.17.21",
"lucide-react": "^0.461.0",
"mermaid": "^11.4.1",
"nanoid": "^5.1.2",
"mdast-util-gfm-autolink-literal": "2.0.0",
"mermaid": "^11.6.0",
"nanoid": "^5.1.5",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hotkeys-hook": "^4.6.1",
"react-i18next": "^15.4.1",
"react-hotkeys-hook": "^4.6.2",
"react-i18next": "^15.5.1",
"react-markdown": "^9.1.0",
"react-router-dom": "^6.30.0",
"react-window": "^1.8.11",
@@ -48,31 +58,39 @@
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
"tauri-plugin-fs-pro-api": "^2.3.1",
"tauri-plugin-macos-permissions-api": "^2.1.1",
"tauri-plugin-screenshots-api": "^2.1.0",
"tailwind-merge": "^3.3.1",
"tauri-plugin-fs-pro-api": "^2.4.0",
"tauri-plugin-macos-permissions-api": "^2.3.0",
"tauri-plugin-screenshots-api": "^2.2.0",
"tauri-plugin-windows-version-api": "^2.0.0",
"type-fest": "^4.41.0",
"use-debounce": "^10.0.4",
"uuid": "^11.1.0",
"zustand": "^5.0.3"
"wavesurfer.js": "^7.9.5",
"zustand": "^5.0.4"
},
"devDependencies": {
"@tauri-apps/cli": "^2.3.1",
"@tauri-apps/cli": "^2.5.0",
"@types/dom-speech-recognition": "^0.0.4",
"@types/lodash-es": "^4.17.12",
"@types/markdown-it": "^14.1.2",
"@types/node": "^22.13.9",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@types/react-i18next": "^8.1.0",
"@types/node": "^22.15.17",
"@types/react": "^18.3.21",
"@types/react-dom": "^18.3.7",
"@types/react-katex": "^3.0.4",
"@types/react-window": "^1.8.8",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"@vitejs/plugin-react": "^4.4.1",
"autoprefixer": "^10.4.21",
"cross-env": "^7.0.3",
"immer": "^10.1.1",
"postcss": "^8.5.3",
"release-it": "^18.1.2",
"sass": "^1.87.0",
"tailwindcss": "^3.4.17",
"tsx": "^4.19.3",
"typescript": "^5.8.2",
"vite": "^5.4.14"
}
}
"tsup": "^8.4.0",
"tsx": "^4.19.4",
"typescript": "^5.8.3",
"vite": "^5.4.19"
},
"packageManager": "pnpm@10.11.0+sha512.6540583f41cc5f628eb3d9773ecee802f4f9ef9923cc45b69890fb47991d4b092964694ec3a4f738a420c918a333062c8b925d312f42e4f0c263eb603551f977"
}

2519
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 845 B

1
scripts/devWeb.ts Normal file
View File

@@ -0,0 +1 @@
(() => {})();

2685
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,9 @@
[package]
name = "coco"
version = "0.2.0"
version = "0.7.1"
description = "Search, connect, collaborate all in one place."
authors = ["INFINI Labs"]
edition = "2021"
edition = "2024"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
@@ -20,34 +20,53 @@ tauri-build = { version = "2", features = ["default"] }
default = ["desktop"]
desktop = []
cargo-clippy = []
# If enabled, code that relies on pizza_engine will be activated.
#
# Only do this if:
# 1. Pizza engine is listed in the `dependencies` section
#
# ```toml
# [dependencies]
# pizza-engine = { git = "ssh://git@github.com/infinilabs/pizza.git", features = ["query_string_parser", "persistence"] }
# ```
#
# 2. It is a private repo, you have access to it.
#
# So, for external contributors, do NOT enable this feature.
#
# Previously, We listed it in the dependencies and marked it optional, but cargo
# would fetch all the dependencies regardless of wheterh they are optional or not,
# so we removed it.
#
# https://github.com/rust-lang/cargo/issues/4544#issuecomment-1906902755
use_pizza_engine = []
[dependencies]
pizza-common = { git = "https://github.com/infinilabs/pizza-common", branch = "main" }
tauri = { version = "2", features = ["protocol-asset", "macos-private-api", "tray-icon", "image-ico", "image-png", "unstable"] }
tauri = { version = "2", features = ["protocol-asset", "macos-private-api", "tray-icon", "image-ico", "image-png"] }
tauri-plugin-shell = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# Need `arbitrary_precision` feature to support storing u128
# 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"
tauri-plugin-dialog = "2"
tauri-plugin-fs = "2"
tauri-plugin-updater = "2"
tauri-plugin-process = "2"
tauri-plugin-drag = "2"
tauri-plugin-macos-permissions = "2"
tauri-plugin-fs-pro = "2"
tauri-plugin-screenshots = "2"
applications = "0.3.0"
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 = ["rustls-tls-webpki-roots"] }
tokio-tungstenite = { version = "0.20", features = ["native-tls"] }
hyper = { version = "0.14", features = ["client"] }
reqwest = "0.12.12"
reqwest = { version = "0.12", features = ["json", "multipart"] }
futures = "0.3.31"
ordered-float = { version = "4.6.0", default-features = false }
lazy_static = "1.5.0"
@@ -60,14 +79,35 @@ hostname = "0.3"
plist = "1.7"
base64 = "0.13"
walkdir = "2"
fuzzy_prefix_search = "0.2"
log = "0.4"
strsim = "0.10"
futures-util = "0.3.31"
url = "2.5.2"
http = "1.1.0"
tungstenite = "0.24.0"
env_logger = "0.11.5"
tokio-util = "0.7.14"
tauri-plugin-windows-version = "2"
meval = { git = "https://github.com/infinilabs/meval-rs" }
chinese-number = "0.7"
num2words = "1"
tauri-plugin-log = "2"
chrono = "0.4.41"
serde_plain = "1.0.2"
derive_more = { version = "2.0.1", features = ["display"] }
anyhow = "1.0.98"
function_name = "0.3.0"
regex = "1.11.1"
borrowme = "0.0.15"
tauri-plugin-opener = "2"
async-recursion = "1.1.1"
zip = "4.0.0"
url = "2.5.2"
camino = "1.1.10"
tokio-stream = { version = "0.1.17", features = ["io-util"] }
cfg-if = "1.0.1"
sysinfo = "0.35.2"
indexmap = { version = "2.10.0", features = ["serde"] }
strum = { version = "0.27.2", features = ["derive"] }
sys-locale = "0.3.2"
[target."cfg(target_os = \"macos\")".dependencies]
tauri-nspanel = { git = "https://github.com/ahkohd/tauri-nspanel", branch = "v2" }
@@ -75,7 +115,6 @@ tauri-nspanel = { git = "https://github.com/ahkohd/tauri-nspanel", branch = "v2"
[target."cfg(any(target_os = \"macos\", windows, target_os = \"linux\"))".dependencies]
tauri-plugin-single-instance = { version = "2.0.0", features = ["deep-link"] }
[profile.dev]
incremental = true # Compile your binary in smaller steps.
@@ -89,3 +128,10 @@ strip = true # Ensures debug symbols are removed.
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-autostart = "^2.2"
tauri-plugin-global-shortcut = "2"
tauri-plugin-updater = { git = "https://github.com/infinilabs/plugins-workspace", branch = "v2" }
# This should be compatible with the semver used by `tauri-plugin-updater`
semver = { version = "1", features = ["serde"] }
[target."cfg(target_os = \"windows\")".dependencies]
enigo="0.3"
windows = { version = "0.61.3", features = ["Win32_Foundation", "Win32_System_Com", "Win32_System_Ole", "Win32_System_Search", "Win32_UI_Shell_PropertiesSystem", "Win32_Data"] }

View File

@@ -31,5 +31,12 @@
</array>
</dict>
</array>
<key>NSMicrophoneUsageDescription</key>
<string>Coco AI needs access to your microphone for voice input and audio recording features.</string>
<key>NSCameraUsageDescription</key>
<string>Coco AI requires camera access for scanning documents and capturing images.</string>
<key>NSSpeechRecognitionUsageDescription</key>
<string>Coco AI uses speech recognition to convert your voice into text for a hands-free experience.</string>
</dict>
</plist>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

View File

@@ -1,3 +1,14 @@
fn main() {
tauri_build::build()
tauri_build::build();
// If env var `GITHUB_ACTIONS` exists, we are running in CI, set up the `ci`
// attribute
if std::env::var("GITHUB_ACTIONS").is_ok() {
println!("cargo:rustc-cfg=ci");
}
// Notify `rustc` of this `cfg` attribute to suppress unknown attribute warnings.
//
// unexpected condition name: `ci`
println!("cargo::rustc-check-cfg=cfg(ci)");
}

View File

@@ -2,7 +2,7 @@
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main", "chat", "settings"],
"windows": ["main", "chat", "settings", "check"],
"permissions": [
"core:default",
"core:event:allow-emit",
@@ -29,6 +29,7 @@
"core:window:allow-set-focus",
"core:window:allow-set-always-on-top",
"core:window:deny-internal-toggle-maximize",
"core:window:allow-set-shadow",
"core:app:allow-set-app-theme",
"shell:default",
"http:default",
@@ -36,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",
@@ -67,6 +65,11 @@
"macos-permissions:default",
"screenshots:default",
"core:window:allow-set-theme",
"process:default"
"process:default",
"updater:default",
"windows-version:default",
"log:default",
"opener:default",
"core:window:allow-unminimize"
]
}

View File

@@ -9,6 +9,7 @@
"autostart:allow-enable",
"autostart:allow-disable",
"autostart:allow-is-enabled",
"global-shortcut:default"
"global-shortcut:default",
"updater:default"
]
}

View File

@@ -0,0 +1,2 @@
[toolchain]
channel = "nightly-2025-06-26"

View File

@@ -1,62 +1,60 @@
use crate::common::assistant::ChatRequestMessage;
use crate::common::http::GetResponse;
use crate::common::http::convert_query_params_to_strings;
use crate::common::register::SearchSourceRegistry;
use crate::server::http_client::HttpClient;
use reqwest::Response;
use crate::{common, server::servers::COCO_SERVERS};
use futures::StreamExt;
use futures::stream::FuturesUnordered;
use futures_util::TryStreamExt;
use http::Method;
use serde_json::Value;
use std::collections::HashMap;
use tauri::{AppHandle, 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,
query: Option<String>,
) -> Result<String, String> {
let mut query_params: HashMap<String, Value> = HashMap::new();
if from > 0 {
query_params.insert("from".to_string(), from.into());
}
if size > 0 {
query_params.insert("size".to_string(), size.into());
let mut query_params = Vec::new();
// Add from/size as number values
query_params.push(format!("from={}", from));
query_params.push(format!("size={}", size));
if let Some(query) = query {
if !query.is_empty() {
query_params.push(format!("query={}", query.to_string()));
}
}
let response = HttpClient::get(&server_id, "/chat/_history", Some(query_params))
.await
.map_err(|e| format!("Error get sessions: {}", e))?;
.map_err(|e| {
dbg!("Error get history: {}", &e);
format!("Error get history: {}", e)
})?;
handle_raw_response(response).await?
}
async fn handle_raw_response(response: Response) -> Result<Result<String, String>, String> {
Ok(
if response.status().as_u16() < 200 || response.status().as_u16() >= 400 {
Err("Failed to send message".to_string())
} else {
let body = response
.text()
.await
.map_err(|e| format!("Failed to parse response JSON: {}", e))?;
Ok(body)
},
)
common::http::get_response_body_text(response).await
}
#[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,
size: u32,
) -> Result<String, String> {
let mut query_params: HashMap<String, Value> = HashMap::new();
if from > 0 {
query_params.insert("from".to_string(), from.into());
}
if size > 0 {
query_params.insert("size".to_string(), size.into());
}
let mut query_params = Vec::new();
// Add from/size as number values
query_params.push(format!("from={}", from));
query_params.push(format!("size={}", size));
let path = format!("/chat/{}/_history", session_id);
@@ -64,112 +62,438 @@ pub async fn session_chat_history<R: Runtime>(
.await
.map_err(|e| format!("Error get session message: {}", e))?;
handle_raw_response(response).await?
common::http::get_response_body_text(response).await
}
#[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> {
let mut query_params = HashMap::new();
let path = format!("/chat/{}/_open", session_id);
let response = HttpClient::post(&server_id, path.as_str(), Some(query_params), None)
let response = HttpClient::post(&server_id, path.as_str(), None, None)
.await
.map_err(|e| format!("Error open session: {}", e))?;
handle_raw_response(response).await?
common::http::get_response_body_text(response).await
}
#[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> {
let mut query_params = HashMap::new();
let path = format!("/chat/{}/_close", session_id);
let response = HttpClient::post(&server_id, path.as_str(), Some(query_params), None)
let response = HttpClient::post(&server_id, path.as_str(), None, None)
.await
.map_err(|e| format!("Error close session: {}", e))?;
handle_raw_response(response).await?
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>>,
) -> Result<String, String> {
let mut query_params = HashMap::new();
let path = format!("/chat/{}/_cancel", session_id);
let query_params = convert_query_params_to_strings(query_params);
let response = HttpClient::post(&server_id, path.as_str(), Some(query_params), None)
let response = HttpClient::post(&server_id, path.as_str(), query_params, None)
.await
.map_err(|e| format!("Error cancel session: {}", e))?;
handle_raw_response(response).await?
common::http::get_response_body_text(response).await
}
#[tauri::command]
pub async fn new_chat<R: Runtime>(
app_handle: AppHandle<R>,
pub async fn chat_create(
app_handle: AppHandle,
server_id: String,
message: String,
query_params: Option<HashMap<String, Value>>, //search,deep_thinking
) -> Result<GetResponse, String> {
let body = if !message.is_empty() {
let message = ChatRequestMessage {
message: Some(message),
message: Option<String>,
attachments: Option<Vec<String>>,
query_params: Option<HashMap<String, Value>>,
client_id: String,
) -> Result<(), String> {
println!("chat_create message: {:?}", message);
println!("chat_create attachments: {:?}", attachments);
let message_empty = message.as_ref().map_or(true, |m| m.is_empty());
let attachments_empty = attachments.as_ref().map_or(true, |a| a.is_empty());
if message_empty && attachments_empty {
return Err("Message and attachments are empty".to_string());
}
let body = {
let request_message: ChatRequestMessage = ChatRequestMessage {
message,
attachments,
};
let body = reqwest::Body::from(serde_json::to_string(&message).unwrap());
Some(body)
} else {
None
println!("chat_create body: {:?}", request_message);
Some(
serde_json::to_string(&request_message)
.map_err(|e| format!("Failed to serialize message: {}", e))?
.into(),
)
};
let response = HttpClient::post(&server_id, "/chat/_new", query_params, body)
.await
.map_err(|e| format!("Error sending message: {}", e))?;
let response = HttpClient::advanced_post(
&server_id,
"/chat/_create",
None,
convert_query_params_to_strings(query_params),
body,
)
.await
.map_err(|e| format!("Error sending message: {}", e))?;
if response.status().as_u16() < 200 || response.status().as_u16() >= 400 {
return Err("Failed to send message".to_string());
if response.status() == 429 {
log::warn!("Rate limit exceeded for chat create");
return Err("Rate limited".to_string());
}
let chat_response: GetResponse = response
.json()
.await
.map_err(|e| format!("Failed to parse response JSON: {}", e))?;
// Check the result and status fields
if chat_response.result != "created" {
return Err(format!("Unexpected result: {}", chat_response.result));
if !response.status().is_success() {
return Err(format!("Request failed with status: {}", response.status()));
}
Ok(chat_response)
let stream = response.bytes_stream();
let reader = tokio_util::io::StreamReader::new(
stream.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e)),
);
let mut lines = tokio::io::BufReader::new(reader).lines();
log::info!("client_id_create: {}", &client_id);
while let Ok(Some(line)) = lines.next_line().await {
log::info!("Received chat stream line: {}", &line);
if let Err(err) = app_handle.emit(&client_id, line) {
log::error!("Emit failed: {:?}", err);
let _ = app_handle.emit("chat-create-error", format!("Emit failed: {:?}", err));
}
}
Ok(())
}
#[tauri::command]
pub async fn send_message<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
) -> Result<String, String> {
let path = format!("/chat/{}/_send", session_id);
let msg = ChatRequestMessage {
message: Some(message),
client_id: String,
) -> Result<(), String> {
println!("chat_chat message: {:?}", message);
println!("chat_chat attachments: {:?}", attachments);
let message_empty = message.as_ref().map_or(true, |m| m.is_empty());
let attachments_empty = attachments.as_ref().map_or(true, |a| a.is_empty());
if message_empty && attachments_empty {
return Err("Message and attachments are empty".to_string());
}
let body = {
let request_message = ChatRequestMessage {
message,
attachments,
};
println!("chat_chat body: {:?}", request_message);
Some(
serde_json::to_string(&request_message)
.map_err(|e| format!("Failed to serialize message: {}", e))?
.into(),
)
};
let body = reqwest::Body::from(serde_json::to_string(&msg).unwrap());
let response =
HttpClient::advanced_post(&server_id, path.as_str(), None, query_params, Some(body))
.await
.map_err(|e| format!("Error cancel session: {}", e))?;
let path = format!("/chat/{}/_chat", session_id);
handle_raw_response(response).await?
let response = HttpClient::advanced_post(
&server_id,
path.as_str(),
None,
convert_query_params_to_strings(query_params),
body,
)
.await
.map_err(|e| format!("Error sending message: {}", e))?;
if response.status() == 429 {
log::warn!("Rate limit exceeded for chat create");
return Err("Rate limited".to_string());
}
if !response.status().is_success() {
return Err(format!("Request failed with status: {}", response.status()));
}
let stream = response.bytes_stream();
let reader = tokio_util::io::StreamReader::new(
stream.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e)),
);
let mut lines = tokio::io::BufReader::new(reader).lines();
let mut first_log = true;
log::info!("client_id: {}", &client_id);
while let Ok(Some(line)) = lines.next_line().await {
log::info!("Received chat stream line: {}", &line);
if first_log {
log::info!("first stream line: {}", &line);
first_log = false;
}
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));
}
}
Ok(())
}
#[tauri::command]
pub async fn delete_session_chat(server_id: String, session_id: String) -> Result<bool, String> {
let response =
HttpClient::delete(&server_id, &format!("/chat/{}", session_id), None, None).await?;
if response.status().is_success() {
Ok(true)
} else {
Err(format!("Delete failed with status: {}", response.status()))
}
}
#[tauri::command]
pub async fn update_session_chat(
server_id: String,
session_id: String,
title: Option<String>,
context: Option<HashMap<String, Value>>,
) -> Result<bool, String> {
let mut body = HashMap::new();
if let Some(title) = title {
body.insert("title".to_string(), Value::String(title));
}
if let Some(context) = context {
body.insert(
"context".to_string(),
Value::Object(context.into_iter().collect()),
);
}
let response = HttpClient::put(
&server_id,
&format!("/chat/{}", session_id),
None,
None,
Some(reqwest::Body::from(serde_json::to_string(&body).unwrap())),
)
.await
.map_err(|e| format!("Error updating session: {}", e))?;
Ok(response.status().is_success())
}
#[tauri::command]
pub async fn assistant_search(
_app_handle: AppHandle,
server_id: String,
query_params: Option<Vec<String>>,
) -> Result<Value, String> {
let response = HttpClient::post(&server_id, "/assistant/_search", query_params, None)
.await
.map_err(|e| format!("Error searching assistants: {}", e))?;
response
.json::<Value>()
.await
.map_err(|err| err.to_string())
}
#[tauri::command]
pub async fn assistant_get(
_app_handle: AppHandle,
server_id: String,
assistant_id: String,
) -> Result<Value, String> {
let response = HttpClient::get(
&server_id,
&format!("/assistant/{}", assistant_id),
None, // headers
)
.await
.map_err(|e| format!("Error getting assistant: {}", e))?;
response
.json::<Value>()
.await
.map_err(|err| err.to_string())
}
/// Gets the information of the assistant specified by `assistant_id` by querying **all**
/// Coco servers.
///
/// Returns as soon as the assistant is found on any Coco server.
#[tauri::command]
pub async fn assistant_get_multi(
app_handle: AppHandle,
assistant_id: String,
) -> Result<Value, String> {
let search_sources = app_handle.state::<SearchSourceRegistry>();
let sources_future = search_sources.get_sources();
let sources_list = sources_future.await;
let mut futures = FuturesUnordered::new();
for query_source in &sources_list {
let query_source_type = query_source.get_type();
if query_source_type.r#type != COCO_SERVERS {
// Assistants only exists on Coco servers.
continue;
}
let coco_server_id = query_source_type.id.clone();
let path = format!("/assistant/{}", assistant_id);
let fut = async move {
let res_response = HttpClient::get(
&coco_server_id,
&path,
None, // headers
)
.await;
match res_response {
Ok(response) => response
.json::<serde_json::Value>()
.await
.map_err(|e| e.to_string()),
Err(e) => Err(e),
}
};
futures.push(fut);
}
while let Some(res_response_json) = futures.next().await {
let response_json = match res_response_json {
Ok(json) => json,
Err(e) => return Err(e),
};
// Example response JSON
//
// When assistant is not found:
// ```json
// {
// "_id": "ID",
// "result": "not_found"
// }
// ```
//
// When assistant is found:
// ```json
// {
// "_id": "ID",
// "_source": {...}
// "found": true
// }
// ```
if let Some(found) = response_json.get("found") {
if found == true {
return Ok(response_json);
}
}
}
Err(format!(
"could not find Assistant [{}] on all the Coco servers",
assistant_id
))
}
use regex::Regex;
/// Remove all `"icon": "..."` fields from a JSON string
pub fn remove_icon_fields(json: &str) -> String {
// Regex to match `"icon": "..."` fields, including base64 or escaped strings
let re = Regex::new(r#""icon"\s*:\s*"[^"]*"(,?)"#).unwrap();
// Replace with empty string, or just remove trailing comma if needed
re.replace_all(json, |caps: &regex::Captures| {
if &caps[1] == "," {
"".to_string() // keep comma removal logic safe
} else {
"".to_string()
}
})
.to_string()
}
#[tauri::command]
pub async fn ask_ai(
app_handle: AppHandle,
message: String,
server_id: String,
assistant_id: String,
client_id: String,
) -> Result<(), String> {
let cleaned = remove_icon_fields(message.as_str());
let body = serde_json::json!({ "message": cleaned });
let path = format!("/assistant/{}/_ask", assistant_id);
println!("Sending request to {}", &path);
let response = HttpClient::send_request(
server_id.as_str(),
Method::POST,
path.as_str(),
None,
None,
Some(reqwest::Body::from(body.to_string())),
)
.await?;
if response.status() == 429 {
log::warn!("Rate limit exceeded for assistant: {}", &assistant_id);
return Ok(());
}
if !response.status().is_success() {
return Err(format!("Request Failed: {}", response.status()));
}
let stream = response.bytes_stream();
let reader = tokio_util::io::StreamReader::new(
stream.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e)),
);
let mut lines = tokio::io::BufReader::new(reader).lines();
while let Ok(Some(line)) = lines.next_line().await {
dbg!("Received line: {}", &line);
let _ = app_handle.emit(&client_id, line).map_err(|err| {
println!("Failed to emit: {:?}", err);
});
}
Ok(())
}

View File

@@ -1,43 +1,48 @@
use std::{fs::create_dir, io::Read};
use tauri::{Manager, Runtime};
use tauri::{AppHandle, Manager};
use tauri_plugin_autostart::ManagerExt;
// Start or stop according to configuration
pub fn enable_autostart(app: &mut tauri::App) {
use tauri_plugin_autostart::MacosLauncher;
use tauri_plugin_autostart::ManagerExt;
/// If the state reported from the OS and the state stored by us differ, our state is
/// prioritized and seen as the correct one. Update the OS state to make them consistent.
pub fn ensure_autostart_state_consistent(tauri_app_handle: &AppHandle) -> Result<(), String> {
let autostart_manager = tauri_app_handle.autolaunch();
app.handle()
.plugin(tauri_plugin_autostart::init(
MacosLauncher::AppleScript,
None,
))
.unwrap();
let os_state = autostart_manager.is_enabled().map_err(|e| e.to_string())?;
let coco_stored_state = current_autostart(tauri_app_handle).map_err(|e| e.to_string())?;
let autostart_manager = app.autolaunch();
if os_state != coco_stored_state {
log::warn!(
"autostart inconsistent states, OS state [{}], Coco state [{}], config file could be deleted or corrupted",
os_state,
coco_stored_state
);
log::info!("trying to correct the inconsistent states");
// close autostart
// autostart_manager.disable().unwrap();
// return;
let result = if coco_stored_state {
autostart_manager.enable()
} else {
autostart_manager.disable()
};
match (
autostart_manager.is_enabled(),
current_autostart(app.app_handle()),
) {
(Ok(false), Ok(true)) => match autostart_manager.enable() {
Ok(_) => println!("Autostart enabled successfully."),
Err(err) => eprintln!("Failed to enable autostart: {}", err),
},
(Ok(true), Ok(false)) => match autostart_manager.disable() {
Ok(_) => println!("Autostart disable successfully."),
Err(err) => eprintln!("Failed to disable autostart: {}", err),
},
_ => (),
match result {
Ok(_) => {
log::info!("inconsistent autostart states fixed");
}
Err(e) => {
log::error!(
"failed to fix inconsistent autostart state due to error [{}]",
e
);
return Err(e.to_string());
}
}
}
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();
@@ -60,7 +65,7 @@ fn current_autostart<R: Runtime>(app: &tauri::AppHandle<R>) -> Result<bool, Stri
}
#[tauri::command]
pub 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,18 +3,22 @@ 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)]
pub struct NewChatResponse {
pub _id: String,
pub _source: Source,
pub _source: Session,
pub result: String,
pub payload: Option<Value>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Source {
pub struct Session {
pub id: String,
pub created: String,
pub updated: String,
@@ -22,4 +26,11 @@ pub struct Source {
pub title: Option<String>,
pub summary: Option<String>,
pub manually_renamed_title: bool,
}
pub visible: Option<bool>,
pub context: Option<SessionContext>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct SessionContext {
pub attachments: Option<Vec<String>>,
}

View File

@@ -1,6 +1,6 @@
use serde::{Deserialize, Serialize};
#[derive(Debug,Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Connector {
pub id: String,
pub created: Option<String>,
@@ -13,7 +13,7 @@ pub struct Connector {
pub url: Option<String>,
pub assets: Option<ConnectorAssets>,
}
#[derive(Debug,Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConnectorAssets {
pub icons: Option<std::collections::HashMap<String, String>>,
}
}

View File

@@ -18,4 +18,4 @@ pub struct DataSource {
pub struct ConnectorConfig {
pub id: Option<String>,
pub config: Option<serde_json::Value>, // Using serde_json::Value to handle any type of config
}
}

View File

@@ -1,5 +1,7 @@
use crate::extension::ExtensionSettings;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use tauri::AppHandle;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RichLabel {
@@ -13,6 +15,7 @@ pub struct DataSourceReference {
pub r#type: Option<String>,
pub name: Option<String>,
pub id: Option<String>,
pub icon: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -28,7 +31,179 @@ pub struct EditorInfo {
pub timestamp: Option<String>,
}
/// Defines the action that would be performed when a [document](Document) gets opened.
///
/// "Document" is a uniform type that the backend uses to send the search results
/// back to the frontend. Since Coco can search many sources, "Document" can
/// represent different things, application, web page, local file, extensions, and
/// so on. Each has its own specific open action.
#[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 {
pub(crate) fn url(&self) -> String {
match self {
Self::Application { app_path } => app_path.clone(),
Self::Document { url } => url.clone(),
Self::Extension(ext_on_opened) => {
match &ext_on_opened.ty {
ExtensionOnOpenedType::Command { action } => {
const WHITESPACE: &str = " ";
let mut ret = action.exec.clone();
ret.push_str(WHITESPACE);
if let Some(ref args) = action.args {
ret.push_str(args.join(WHITESPACE).as_str());
}
ret
}
// Currently, our URL is static and does not support dynamic parameters.
// The URL of a quicklink is nearly useless without such dynamic user
// inputs, so until we have dynamic URL support, we just use "N/A".
ExtensionOnOpenedType::Quicklink { .. } => String::from("N/A"),
}
}
}
}
}
#[tauri::command]
pub(crate) async fn open(
tauri_app_handle: AppHandle,
on_opened: OnOpened,
extra_args: Option<HashMap<String, String>>,
) -> Result<(), String> {
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::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;
}
}
}
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?
}
}
}
}
}
}
Ok(())
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Document {
pub id: String,
pub created: Option<String>,
@@ -47,6 +222,8 @@ pub struct Document {
pub thumbnail: Option<String>,
pub cover: Option<String>,
pub tags: Option<Vec<String>>,
/// What will happen if we open this document.
pub on_opened: Option<OnOpened>,
pub url: Option<String>,
pub size: Option<i64>,
pub metadata: Option<HashMap<String, serde_json::Value>>,
@@ -54,32 +231,3 @@ pub struct Document {
pub owner: Option<UserInfo>,
pub last_updated_by: Option<EditorInfo>,
}
impl Document {
pub fn new(source: Option<DataSourceReference>, id: String, category: String, name: String, url: String) -> Self {
Self {
id,
created: None,
updated: None,
source,
r#type: None,
category: Some(category),
subcategory: None,
categories: None,
rich_categories: None,
title: Some(name),
summary: None,
lang: None,
content: None,
icon: None,
thumbnail: None,
cover: None,
tags: None,
url: Some(url),
size: None,
metadata: None,
payload: None,
owner: None,
last_updated_by: None,
}
}
}

View File

@@ -0,0 +1,81 @@
use reqwest::StatusCode;
use serde::{Deserialize, Serialize, Serializer};
use thiserror::Error;
fn serialize_optional_status_code<S>(
status_code: &Option<StatusCode>,
serializer: S,
) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
match status_code {
Some(code) => serializer.serialize_str(&format!("{:?}", code)),
None => serializer.serialize_none(),
}
}
#[allow(unused)]
#[derive(Debug, Deserialize)]
pub struct ErrorCause {
#[serde(default)]
pub r#type: Option<String>,
#[serde(default)]
pub reason: Option<String>,
}
#[derive(Debug, Deserialize)]
#[allow(unused)]
pub struct ErrorDetail {
#[serde(default)]
pub root_cause: Option<Vec<ErrorCause>>,
#[serde(default)]
pub r#type: Option<String>,
#[serde(default)]
pub reason: Option<String>,
#[serde(default)]
pub caused_by: Option<ErrorCause>,
}
#[derive(Debug, Deserialize)]
pub struct ErrorResponse {
#[serde(default)]
pub error: Option<ErrorDetail>,
#[serde(default)]
#[allow(unused)]
pub status: Option<u16>,
}
#[derive(Debug, Error, Serialize)]
pub enum SearchError {
#[error("HttpError: status code [{status_code:?}], msg [{msg}]")]
HttpError {
#[serde(serialize_with = "serialize_optional_status_code")]
status_code: Option<StatusCode>,
msg: String,
},
#[error("ParseError: {0}")]
ParseError(String),
#[error("Timeout occurred")]
Timeout,
#[error("InternalError: {0}")]
InternalError(String),
}
impl From<reqwest::Error> for SearchError {
fn from(err: reqwest::Error) -> Self {
if err.is_timeout() {
SearchError::Timeout
} else if err.is_decode() {
SearchError::ParseError(err.to_string())
} else {
SearchError::HttpError {
status_code: err.status(),
msg: err.to_string(),
}
}
}
}

View File

@@ -1,5 +1,9 @@
use crate::common;
use reqwest::Response;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
use tauri_plugin_store::JsonValue;
#[derive(Debug, Serialize, Deserialize)]
pub struct GetResponse {
@@ -15,4 +19,57 @@ pub struct Source {
pub created: String,
pub updated: String,
pub status: String,
}
}
pub async fn get_response_body_text(response: Response) -> Result<String, String> {
let status = response.status().as_u16();
let body = response
.text()
.await
.map_err(|e| format!("Failed to read response body: {}, code: {}", e, status))?;
log::debug!("Response status: {}, body: {}", status, &body);
if status < 200 || status >= 400 {
// Try to parse the error body
let fallback_error = "Failed to send message".to_string();
if body.trim().is_empty() {
return Err(fallback_error);
}
match serde_json::from_str::<common::error::ErrorResponse>(&body) {
Ok(parsed_error) => {
dbg!(&parsed_error);
Err(format!(
"Server error ({}): {:?}",
status, parsed_error.error
))
}
Err(_) => {
log::warn!("Failed to parse error response: {}", &body);
Err(fallback_error)
}
}
} else {
Ok(body)
}
}
pub fn convert_query_params_to_strings(
query_params: Option<HashMap<String, JsonValue>>,
) -> Option<Vec<String>> {
query_params.map(|map| {
map.into_iter()
.filter_map(|(k, v)| match v {
JsonValue::String(s) => Some(format!("{}={}", k, s)),
JsonValue::Number(n) => Some(format!("{}={}", k, n)),
JsonValue::Bool(b) => Some(format!("{}={}", k, b)),
_ => {
eprintln!("Skipping unsupported query value for key '{}': {:?}", k, v);
None
}
})
.collect()
})
}

View File

@@ -1,15 +1,17 @@
pub mod health;
pub mod profile;
pub mod server;
pub mod auth;
pub mod datasource;
pub mod connector;
pub mod search;
pub mod document;
pub mod traits;
pub mod register;
pub mod assistant;
pub mod auth;
pub mod connector;
pub mod datasource;
pub mod document;
pub mod error;
pub mod health;
pub mod http;
pub mod profile;
pub mod register;
pub mod search;
pub mod server;
pub mod traits;
pub static MAIN_WINDOW_LABEL: &str = "main";
pub static SETTINGS_WINDOW_LABEL: &str = "settings";
pub static CHECK_WINDOW_LABEL: &str = "check";

View File

@@ -1,15 +1,16 @@
use serde::{Serialize, Deserialize};
use serde::{Deserialize, Serialize};
#[derive(Debug,Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Preferences {
pub theme: String,
pub language: String,
pub theme: Option<String>,
pub language: Option<String>,
}
#[derive(Debug,Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserProfile {
pub id: String,
pub name: String,
pub email: String,
pub avatar: String,
pub preferences: Preferences,
}
pub avatar: Option<String>,
pub preferences: Option<Preferences>,
}

View File

@@ -16,6 +16,7 @@ impl SearchSourceRegistry {
sources.insert(source_id, Arc::new(source));
}
#[allow(dead_code)]
pub async fn clear(&self) {
let mut sources = self.sources.write().await;
sources.clear();
@@ -26,6 +27,7 @@ impl SearchSourceRegistry {
sources.remove(id);
}
#[allow(dead_code)]
pub async fn get_source(&self, id: &str) -> Option<Arc<dyn SearchSource>> {
let sources = self.sources.read().await;
sources.get(id).cloned()
@@ -34,4 +36,4 @@ impl SearchSourceRegistry {
let sources = self.sources.read().await;
sources.values().cloned().collect() // Returns Vec<Arc<dyn SearchSource>>
}
}
}

View File

@@ -1,14 +1,15 @@
use crate::common::document::Document;
use crate::common::http::get_response_body_text;
use reqwest::Response;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
use std::error::Error;
#[derive(Debug, Serialize, Deserialize)]
pub struct SearchResponse<T> {
pub took: u64,
pub timed_out: bool,
pub _shards: Shards,
pub took: Option<u64>,
pub timed_out: Option<bool>,
pub _shards: Option<Shards>,
pub hits: Hits<T>,
}
@@ -24,7 +25,7 @@ pub struct Shards {
pub struct Hits<T> {
pub total: Total,
pub max_score: Option<f32>,
pub hits: Vec<SearchHit<T>>,
pub hits: Option<Vec<SearchHit<T>>>,
}
#[derive(Debug, Serialize, Deserialize)]
@@ -35,9 +36,9 @@ pub struct Total {
#[derive(Debug, Serialize, Deserialize)]
pub struct SearchHit<T> {
pub _index: String,
pub _type: String,
pub _id: String,
pub _index: Option<String>,
pub _type: Option<String>,
pub _id: Option<String>,
pub _score: Option<f64>,
pub _source: T, // This will hold the type we pass in (e.g., DataSource)
}
@@ -47,26 +48,28 @@ pub async fn parse_search_response<T>(
where
T: for<'de> Deserialize<'de> + std::fmt::Debug,
{
let body = response
.json::<Value>()
.await
.map_err(|e| format!("Failed to parse JSON: {}", e))?;
let body_text = get_response_body_text(response).await?;
// dbg!(&body);
// dbg!(&body_text);
let search_response: SearchResponse<T> = serde_json::from_value(body)
let search_response: SearchResponse<T> = serde_json::from_str(&body_text)
.map_err(|e| format!("Failed to deserialize search response: {}", e))?;
Ok(search_response)
}
use serde::de::DeserializeOwned;
pub async fn parse_search_hits<T>(response: Response) -> Result<Vec<SearchHit<T>>, Box<dyn Error>>
where
T: for<'de> Deserialize<'de> + std::fmt::Debug,
T: DeserializeOwned + std::fmt::Debug,
{
let response = parse_search_response(response).await?;
Ok(response.hits.hits)
match response.hits.hits {
Some(hits) => Ok(hits),
None => Ok(Vec::new()),
}
}
pub async fn parse_search_results<T>(response: Response) -> Result<Vec<T>, Box<dyn Error>>
@@ -80,19 +83,6 @@ where
.collect())
}
pub async fn parse_search_results_with_score<T>(
response: Response,
) -> Result<Vec<(T, Option<f64>)>, Box<dyn Error>>
where
T: for<'de> Deserialize<'de> + std::fmt::Debug,
{
Ok(parse_search_hits(response)
.await?
.into_iter()
.map(|hit| (hit._source, hit._score))
.collect())
}
#[derive(Debug, Clone, Serialize)]
pub struct SearchQuery {
pub from: u64,

View File

@@ -1,6 +1,8 @@
use crate::common::health::Health;
use crate::common::profile::UserProfile;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
use std::hash::{Hash, Hasher};
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -29,6 +31,11 @@ pub struct AuthProvider {
pub sso: Sso,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MinimalClientVersion {
number: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Server {
#[serde(default = "default_empty_string")] // Custom default function for empty string
@@ -39,12 +46,21 @@ pub struct Server {
pub endpoint: String,
pub provider: Provider,
pub version: Version,
pub minimal_client_version: Option<MinimalClientVersion>,
pub updated: String,
#[serde(default = "default_enabled_type")]
pub enabled: bool,
/// Public Coco servers can be used without signing in.
#[serde(default = "default_bool_type")]
pub public: bool,
/// A coco server is available if:
///
/// 1. It is still online, we check this via the `GET /base_url/provider/_info`
/// interface.
/// 2. A user is logged in to this Coco server, i.e., a token is stored in the
/// `SERVER_TOKEN_LIST_CACHE`.
/// For public Coco servers, requirement 2 is not needed.
#[serde(default = "default_available_type")]
pub available: bool,
@@ -54,6 +70,7 @@ pub struct Server {
pub auth_provider: AuthProvider,
#[serde(default = "default_priority_type")]
pub priority: u32,
pub stats: Option<HashMap<String, Value>>,
}
impl PartialEq for Server {
@@ -70,13 +87,15 @@ impl Hash for Server {
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServerAccessToken {
#[serde(default = "default_empty_string")] // Custom default function for empty string
pub id: String,
pub access_token: String,
pub expired_at: u32, //unix timestamp in seconds
/// Unix timestamp in seconds
///
/// Currently, this is UNUSED.
pub expired_at: u32,
}
impl ServerAccessToken {
@@ -104,11 +123,11 @@ impl Hash for ServerAccessToken {
}
fn default_empty_string() -> String {
"".to_string() // Default to empty string if not provided
"".to_string() // Default to empty string if not provided
}
fn default_bool_type() -> bool {
false // Default to false if not provided
false // Default to false if not provided
}
fn default_enabled_type() -> bool {
@@ -123,4 +142,4 @@ fn default_priority_type() -> u32 {
}
fn default_user_profile_type() -> Option<UserProfile> {
None
}
}

View File

@@ -1,44 +1,16 @@
use crate::common::search::{QueryResponse, QuerySource};
use thiserror::Error;
use async_trait::async_trait;
// use std::{future::Future, pin::Pin};
use crate::common::error::SearchError;
use crate::common::search::SearchQuery;
use serde::Serialize;
use crate::common::search::{QueryResponse, QuerySource};
use async_trait::async_trait;
use tauri::AppHandle;
#[async_trait]
pub trait SearchSource: Send + Sync {
fn get_type(&self) -> QuerySource;
async fn search(&self, query: SearchQuery) -> Result<QueryResponse, SearchError>;
}
#[derive(Debug, Error, Serialize)]
pub enum SearchError {
#[error("HTTP request failed: {0}")]
HttpError(String),
#[error("Invalid response format: {0}")]
ParseError(String),
#[error("Timeout occurred")]
Timeout,
#[error("Unknown error: {0}")]
Unknown(String),
#[error("InternalError error: {0}")]
InternalError(String),
}
impl From<reqwest::Error> for SearchError {
fn from(err: reqwest::Error) -> Self {
if err.is_timeout() {
SearchError::Timeout
} else if err.is_decode() {
SearchError::ParseError(err.to_string())
} else {
SearchError::HttpError(err.to_string())
}
}
async fn search(
&self,
tauri_app_handle: AppHandle,
query: SearchQuery,
) -> Result<QueryResponse, SearchError>;
}

View File

@@ -0,0 +1,13 @@
pub(super) const EXTENSION_ID: &str = "AIOverview";
/// JSON file for this extension.
pub(crate) const PLUGIN_JSON_FILE: &str = r#"
{
"id": "AIOverview",
"name": "AI Overview",
"description": "...",
"icon": "font_a-AIOverview",
"type": "ai_extension",
"enabled": true
}
"#;

View File

@@ -0,0 +1,50 @@
use serde::Serialize;
#[cfg(feature = "use_pizza_engine")]
mod with_feature;
#[cfg(not(feature = "use_pizza_engine"))]
mod without_feature;
#[cfg(feature = "use_pizza_engine")]
pub use with_feature::*;
#[cfg(not(feature = "use_pizza_engine"))]
pub use without_feature::*;
#[derive(Debug, Serialize, Clone)]
#[serde(rename_all = "camelCase")]
#[allow(dead_code)]
pub struct AppEntry {
path: String,
name: String,
icon_path: String,
alias: String,
hotkey: String,
is_disabled: bool,
}
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AppMetadata {
name: String,
r#where: String,
size: u64,
created: u128,
modified: u128,
last_opened: u128,
}
/// JSON file for this extension.
pub(crate) const PLUGIN_JSON_FILE: &str = r#"
{
"id": "Applications",
"platforms": ["macos", "linux", "windows"],
"name": "Applications",
"description": "Application search",
"icon": "font_Application",
"type": "group",
"enabled": true
}
"#;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,128 @@
use super::super::Extension;
use super::AppMetadata;
use crate::common::error::SearchError;
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;
pub(crate) const QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME: &str = "Applications";
pub struct ApplicationSearchSource;
impl ApplicationSearchSource {
pub async fn prepare_index_and_store(_app_handle: AppHandle) -> Result<(), String> {
Ok(())
}
}
#[async_trait]
impl SearchSource for ApplicationSearchSource {
fn get_type(&self) -> QuerySource {
QuerySource {
r#type: LOCAL_QUERY_SOURCE_TYPE.into(),
name: hostname::get()
.unwrap_or("My Computer".into())
.to_string_lossy()
.into(),
id: QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME.into(),
}
}
async fn search(
&self,
_tauri_app_handle: AppHandle,
_query: SearchQuery,
) -> Result<QueryResponse, SearchError> {
Ok(QueryResponse {
source: self.get_type(),
hits: Vec::new(),
total_hits: 0,
})
}
}
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(
_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(_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(_tauri_app_handle: &AppHandle, _app_path: &str) -> Result<(), String> {
// no-op
Ok(())
}
pub fn enable_app_search(_tauri_app_handle: &AppHandle, _app_path: &str) -> Result<(), String> {
// no-op
Ok(())
}
pub fn is_app_search_enabled(_app_path: &str) -> bool {
false
}
#[tauri::command]
pub async fn add_app_search_path(
_tauri_app_handle: AppHandle,
_search_path: String,
) -> Result<(), String> {
// no-op
Ok(())
}
#[tauri::command]
pub async fn remove_app_search_path(
_tauri_app_handle: AppHandle,
_search_path: String,
) -> Result<(), String> {
// no-op
Ok(())
}
#[tauri::command]
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(_tauri_app_handle: AppHandle) -> Result<Vec<Extension>, String> {
// Return an empty list
Ok(Vec::new())
}
#[tauri::command]
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(_tauri_app_handle: &AppHandle) -> Result<(), String> {
// no-op
Ok(())
}
pub(crate) fn unset_apps_hotkey(_tauri_app_handle: &AppHandle) -> Result<(), String> {
// no-op
Ok(())
}
#[tauri::command]
pub async fn reindex_applications(_tauri_app_handle: AppHandle) -> Result<(), String> {
// no-op
Ok(())
}

View File

@@ -0,0 +1,199 @@
use super::super::LOCAL_QUERY_SOURCE_TYPE;
use crate::common::{
document::{DataSourceReference, Document},
error::SearchError,
search::{QueryResponse, QuerySource, SearchQuery},
traits::SearchSource,
};
use async_trait::async_trait;
use chinese_number::{ChineseCase, ChineseCountMethod, ChineseVariant, NumberToChinese};
use num2words::Num2Words;
use serde_json::Value;
use std::collections::HashMap;
use tauri::AppHandle;
pub(crate) const DATA_SOURCE_ID: &str = "Calculator";
/// JSON file for this extension.
pub(crate) const PLUGIN_JSON_FILE: &str = r#"
{
"id": "Calculator",
"name": "Calculator",
"platforms": ["macos", "linux", "windows"],
"description": "...",
"icon": "font_Calculator",
"type": "calculator",
"enabled": true
}
"#;
pub struct CalculatorSource {
base_score: f64,
}
impl CalculatorSource {
pub fn new(base_score: f64) -> Self {
CalculatorSource { base_score }
}
}
fn parse_query(query: &str) -> Value {
let mut query_json = serde_json::Map::new();
let operators = ["+", "-", "*", "/", "%"];
let found_operators: Vec<_> = query
.chars()
.filter(|c| operators.contains(&c.to_string().as_str()))
.collect();
if found_operators.len() == 1 {
let operation = match found_operators[0] {
'+' => "sum",
'-' => "subtract",
'*' => "multiply",
'/' => "divide",
'%' => "remainder",
_ => "expression",
};
query_json.insert("type".to_string(), Value::String(operation.to_string()));
} else {
query_json.insert("type".to_string(), Value::String("expression".to_string()));
}
query_json.insert("value".to_string(), Value::String(query.to_string()));
Value::Object(query_json)
}
fn parse_result(num: f64) -> Value {
let mut result_json = serde_json::Map::new();
let to_zh = num
.to_chinese(
ChineseVariant::Simple,
ChineseCase::Upper,
ChineseCountMethod::TenThousand,
)
.unwrap_or(num.to_string());
let to_en = Num2Words::new(num)
.to_words()
.map(|s| {
let mut chars = s.chars();
let mut result = String::new();
let mut capitalize = true;
while let Some(c) = chars.next() {
if c == ' ' || c == '-' {
result.push(c);
capitalize = true;
} else if capitalize {
result.extend(c.to_uppercase());
capitalize = false;
} else {
result.push(c);
}
}
result
})
.unwrap_or(num.to_string());
result_json.insert("value".to_string(), Value::String(num.to_string()));
result_json.insert("toZh".to_string(), Value::String(to_zh));
result_json.insert("toEn".to_string(), Value::String(to_en));
Value::Object(result_json)
}
#[async_trait]
impl SearchSource for CalculatorSource {
fn get_type(&self) -> QuerySource {
QuerySource {
r#type: LOCAL_QUERY_SOURCE_TYPE.into(),
name: hostname::get()
.unwrap_or(DATA_SOURCE_ID.into())
.to_string_lossy()
.into(),
id: DATA_SOURCE_ID.into(),
}
}
async fn search(
&self,
_tauri_app_handle: AppHandle,
query: SearchQuery,
) -> Result<QueryResponse, SearchError> {
let Some(query_string) = query.query_strings.get("query") else {
return Ok(QueryResponse {
source: self.get_type(),
hits: Vec::new(),
total_hits: 0,
});
};
// Trim the leading and tailing whitespace so that our later if condition
// will only be evaluated against non-whitespace characters.
let query_string = query_string.trim();
if query_string.is_empty() || query_string.len() == 1 {
return Ok(QueryResponse {
source: self.get_type(),
hits: Vec::new(),
total_hits: 0,
});
}
let query_string_clone = query_string.to_string();
let query_source = self.get_type();
let base_score = self.base_score;
let closure = move || -> QueryResponse {
let res_num = meval::eval_str(&query_string_clone);
match res_num {
Ok(num) => {
let mut payload: HashMap<String, Value> = HashMap::new();
let payload_query = parse_query(&query_string_clone);
let payload_result = parse_result(num);
payload.insert("query".to_string(), payload_query);
payload.insert("result".to_string(), payload_result);
let doc = Document {
id: DATA_SOURCE_ID.to_string(),
category: Some(DATA_SOURCE_ID.to_string()),
payload: Some(payload),
source: Some(DataSourceReference {
r#type: Some(LOCAL_QUERY_SOURCE_TYPE.into()),
name: Some(DATA_SOURCE_ID.into()),
id: Some(DATA_SOURCE_ID.into()),
icon: Some(String::from("font_Calculator")),
}),
..Default::default()
};
QueryResponse {
source: query_source,
hits: vec![(doc, base_score)],
total_hits: 1,
}
}
Err(_) => QueryResponse {
source: query_source,
hits: Vec::new(),
total_hits: 0,
},
}
};
let spawn_result = tokio::task::spawn_blocking(closure).await;
match spawn_result {
Ok(response) => Ok(response),
Err(e) => std::panic::resume_unwind(e.into_panic()),
}
}
}

View File

@@ -0,0 +1,209 @@
//! File Search configuration entries definition and getter/setter functions.
use serde::Deserialize;
use serde::Serialize;
use serde_json::Value;
use std::sync::LazyLock;
use tauri::AppHandle;
use tauri_plugin_store::StoreExt;
// Tauri store keys for file system configuration
const TAURI_STORE_FILE_SYSTEM_CONFIG: &str = "file_system_config";
const TAURI_STORE_KEY_SEARCH_BY: &str = "search_by";
const TAURI_STORE_KEY_SEARCH_PATHS: &str = "search_paths";
const TAURI_STORE_KEY_EXCLUDE_PATHS: &str = "exclude_paths";
const TAURI_STORE_KEY_FILE_TYPES: &str = "file_types";
static HOME_DIR: LazyLock<String> = LazyLock::new(|| {
let os_string = dirs::home_dir()
.expect("$HOME should be set")
.into_os_string();
os_string
.into_string()
.expect("User home directory should be encoded with UTF-8")
});
#[derive(Debug, Clone, Serialize, Deserialize, Copy)]
pub enum SearchBy {
Name,
NameAndContents,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileSearchConfig {
pub search_paths: Vec<String>,
pub exclude_paths: Vec<String>,
pub file_types: Vec<String>,
pub search_by: SearchBy,
}
impl Default for FileSearchConfig {
fn default() -> Self {
Self {
search_paths: vec![
format!("{}/Documents", HOME_DIR.as_str()),
format!("{}/Desktop", HOME_DIR.as_str()),
format!("{}/Downloads", HOME_DIR.as_str()),
],
exclude_paths: Vec::new(),
file_types: Vec::new(),
search_by: SearchBy::Name,
}
}
}
impl FileSearchConfig {
pub(crate) fn get(tauri_app_handle: &AppHandle) -> Self {
let store = tauri_app_handle
.store(TAURI_STORE_FILE_SYSTEM_CONFIG)
.unwrap_or_else(|e| {
panic!(
"store [{}] not found/loaded, error [{}]",
TAURI_STORE_FILE_SYSTEM_CONFIG, e
)
});
// Default value, will be used when specific config entries are not set
let default_config = FileSearchConfig::default();
let search_paths = {
if let Some(search_paths) = store.get(TAURI_STORE_KEY_SEARCH_PATHS) {
match search_paths {
Value::Array(arr) => {
let mut vec = Vec::with_capacity(arr.len());
for v in arr {
match v {
Value::String(s) => vec.push(s),
other => panic!(
"Expected all elements of 'search_paths' to be strings, but found: {:?}",
other
),
}
}
vec
}
other => panic!(
"Expected 'search_paths' to be an array of strings in the file system config store, but got: {:?}",
other
),
}
} else {
store.set(
TAURI_STORE_KEY_SEARCH_PATHS,
default_config.search_paths.as_slice(),
);
default_config.search_paths
}
};
let exclude_paths = {
if let Some(exclude_paths) = store.get(TAURI_STORE_KEY_EXCLUDE_PATHS) {
match exclude_paths {
Value::Array(arr) => {
let mut vec = Vec::with_capacity(arr.len());
for v in arr {
match v {
Value::String(s) => vec.push(s),
other => panic!(
"Expected all elements of 'exclude_paths' to be strings, but found: {:?}",
other
),
}
}
vec
}
other => panic!(
"Expected 'exclude_paths' to be an array of strings in the file system config store, but got: {:?}",
other
),
}
} else {
store.set(
TAURI_STORE_KEY_EXCLUDE_PATHS,
default_config.exclude_paths.as_slice(),
);
default_config.exclude_paths
}
};
let file_types = {
if let Some(file_types) = store.get(TAURI_STORE_KEY_FILE_TYPES) {
match file_types {
Value::Array(arr) => {
let mut vec = Vec::with_capacity(arr.len());
for v in arr {
match v {
Value::String(s) => vec.push(s),
other => panic!(
"Expected all elements of 'file_types' to be strings, but found: {:?}",
other
),
}
}
vec
}
other => panic!(
"Expected 'file_types' to be an array of strings in the file system config store, but got: {:?}",
other
),
}
} else {
store.set(
TAURI_STORE_KEY_FILE_TYPES,
default_config.file_types.as_slice(),
);
default_config.file_types
}
};
let search_by = {
if let Some(search_by) = store.get(TAURI_STORE_KEY_SEARCH_BY) {
serde_json::from_value(search_by.clone()).unwrap_or_else(|e| {
panic!(
"Failed to deserialize 'search_by' from file system config store. Invalid JSON: {:?}, error: {}",
search_by, e
)
})
} else {
store.set(
TAURI_STORE_KEY_SEARCH_BY,
serde_json::to_value(default_config.search_by).unwrap(),
);
default_config.search_by
}
};
Self {
search_by,
search_paths,
exclude_paths,
file_types,
}
}
}
// Tauri commands for managing file system configuration
#[tauri::command]
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(
tauri_app_handle: AppHandle,
config: FileSearchConfig,
) -> Result<(), String> {
let store = tauri_app_handle
.store(TAURI_STORE_FILE_SYSTEM_CONFIG)
.map_err(|e| e.to_string())?;
store.set(TAURI_STORE_KEY_SEARCH_PATHS, config.search_paths);
store.set(TAURI_STORE_KEY_EXCLUDE_PATHS, config.exclude_paths);
store.set(TAURI_STORE_KEY_FILE_TYPES, config.file_types);
store.set(
TAURI_STORE_KEY_SEARCH_BY,
serde_json::to_value(config.search_by).unwrap(),
);
Ok(())
}

View File

@@ -0,0 +1,186 @@
use super::super::EXTENSION_ID;
use super::super::config::FileSearchConfig;
use super::super::config::SearchBy;
use crate::common::document::{DataSourceReference, Document};
use crate::extension::LOCAL_QUERY_SOURCE_TYPE;
use crate::extension::OnOpened;
use crate::util::file::get_file_icon;
use futures::stream::Stream;
use futures::stream::StreamExt;
use std::os::fd::OwnedFd;
use std::path::Path;
use tokio::io::AsyncBufReadExt;
use tokio::io::BufReader;
use tokio::process::Child;
use tokio::process::Command;
use tokio_stream::wrappers::LinesStream;
/// `mdfind` won't return scores, we use this score for all the documents.
const SCORE: f64 = 1.0;
pub(crate) async fn hits(
query_string: &str,
from: usize,
size: usize,
config: &FileSearchConfig,
) -> Result<Vec<(Document, f64)>, String> {
let (mut iter, mut mdfind_child_process) =
execute_mdfind_query(&query_string, from, size, &config)?;
// Convert results to documents
let mut hits: Vec<(Document, f64)> = Vec::new();
while let Some(res_file_path) = iter.next().await {
let file_path = res_file_path.map_err(|io_err| io_err.to_string())?;
let icon = get_file_icon(file_path.clone()).await;
let file_path_of_type_path = camino::Utf8Path::new(&file_path);
let r#where = file_path_of_type_path
.parent()
.unwrap_or_else(|| {
panic!(
"expect path [{}] to have a parent, but it does not",
file_path
);
})
.to_string();
let file_name = file_path_of_type_path.file_name().unwrap_or_else(|| {
panic!(
"expect path [{}] to have a file name, but it does not",
file_path
);
});
let on_opened = OnOpened::Document {
url: file_path.clone(),
};
let doc = Document {
id: file_path.clone(),
title: Some(file_name.to_string()),
source: Some(DataSourceReference {
r#type: Some(LOCAL_QUERY_SOURCE_TYPE.into()),
name: Some(EXTENSION_ID.into()),
id: Some(EXTENSION_ID.into()),
icon: Some(String::from("font_Filesearch")),
}),
category: Some(r#where),
on_opened: Some(on_opened),
url: Some(file_path),
icon: Some(icon.to_string()),
..Default::default()
};
hits.push((doc, SCORE));
}
// Kill the mdfind process once we get the needed results to prevent zombie
// processes.
mdfind_child_process
.kill()
.await
.map_err(|e| format!("{:?}", e))?;
Ok(hits)
}
/// Return an array containing the `mdfind` command and its arguments.
fn build_mdfind_query(query_string: &str, config: &FileSearchConfig) -> Vec<String> {
let mut args = vec!["mdfind".to_string()];
match config.search_by {
SearchBy::Name => {
args.push(format!("kMDItemFSName == '*{}*'", query_string));
}
SearchBy::NameAndContents => {
args.push(format!(
"kMDItemFSName == '*{}*' || kMDItemTextContent == '{}'",
query_string, query_string
));
}
}
// Add search paths using -onlyin
for path in &config.search_paths {
if Path::new(path).exists() {
args.extend_from_slice(&["-onlyin".to_string(), path.to_string()]);
}
}
args
}
/// Spawn the `mdfind` child process and return an async iterator over its output,
/// allowing us to collect the results asynchronously.
///
/// # Return value:
///
/// * impl Stream: an async iterator that will yield the matched files
/// * Child: The handle to the mdfind process, we need to kill it once we
/// collect all the results to avoid zombie processes.
fn execute_mdfind_query(
query_string: &str,
from: usize,
size: usize,
config: &FileSearchConfig,
) -> Result<(impl Stream<Item = std::io::Result<String>>, Child), String> {
let args = build_mdfind_query(query_string, &config);
let (rx, tx) = std::io::pipe().unwrap();
let rx_owned = OwnedFd::from(rx);
let async_rx = tokio::net::unix::pipe::Receiver::from_owned_fd(rx_owned).unwrap();
let buffered_rx = BufReader::new(async_rx);
let lines = LinesStream::new(buffered_rx.lines());
let child = Command::new(&args[0])
.args(&args[1..])
.stdout(tx)
.stderr(std::process::Stdio::null())
.spawn()
.map_err(|e| format!("Failed to spawn mdfind: {}", e))?;
let config_clone = config.clone();
let iter = lines
.filter(move |res_path| {
std::future::ready({
match res_path {
Ok(path) => !should_be_filtered_out(&config_clone, path),
Err(_) => {
// Don't filter out Err() values
true
}
}
})
})
.skip(from)
.take(size);
Ok((iter, child))
}
/// If `file_path` should be removed from the search results given the filter
/// conditions specified in `config`.
fn should_be_filtered_out(config: &FileSearchConfig, file_path: &str) -> bool {
let is_excluded = config
.exclude_paths
.iter()
.any(|exclude_path| file_path.starts_with(exclude_path));
if is_excluded {
return true;
}
let matches_file_type = if config.file_types.is_empty() {
true
} else {
let path_obj = camino::Utf8Path::new(&file_path);
if let Some(extension) = path_obj.extension() {
config
.file_types
.iter()
.any(|file_type| file_type == extension)
} else {
// `config.file_types` is not empty, then the search results
// should have extensions.
false
}
};
!matches_file_type
}

View File

@@ -0,0 +1,10 @@
#[cfg(target_os = "macos")]
mod macos;
#[cfg(target_os = "windows")]
mod windows;
// `hits()` function is platform-specific, export the corresponding impl.
#[cfg(target_os = "macos")]
pub(crate) use macos::hits;
#[cfg(target_os = "windows")]
pub(crate) use windows::hits;

View File

@@ -0,0 +1,751 @@
//! # Credits
//!
//! https://github.com/IRONAGE-Park/rag-sample/blob/3f0ad8c8012026cd3a7e453d08f041609426cb91/src/native/windows.rs
//! is the starting point of this implementation.
use super::super::EXTENSION_ID;
use super::super::config::FileSearchConfig;
use super::super::config::SearchBy;
use crate::common::document::{DataSourceReference, Document};
use crate::extension::LOCAL_QUERY_SOURCE_TYPE;
use crate::extension::OnOpened;
use crate::util::file::get_file_icon;
use windows::{
Win32::System::{
Com::{CLSCTX_INPROC_SERVER, CoCreateInstance},
Ole::{OleInitialize, OleUninitialize},
Search::{
DB_NULL_HCHAPTER, DBACCESSOR_ROWDATA, DBBINDING, DBMEMOWNER_CLIENTOWNED,
DBPARAMIO_NOTPARAM, DBPART_VALUE, DBTYPE_WSTR, HACCESSOR, IAccessor, ICommand,
ICommandText, IDBCreateCommand, IDBCreateSession, IDBInitialize, IDataInitialize,
IRowset, MSDAINITIALIZE,
},
},
core::{GUID, IUnknown, Interface, PWSTR, w},
};
/// Owned version of `PWSTR` that holds the heap memory.
///
/// Use `as_pwstr()` to convert it to a raw pointer.
struct PwStrOwned(Vec<u16>);
impl PwStrOwned {
/// # SAFETY
///
/// The returned `PWSTR` is basically a raw pointer, it is only valid within the
/// lifetime of `PwStrOwned`.
unsafe fn as_pwstr(&mut self) -> PWSTR {
let raw_ptr = self.0.as_mut_ptr();
PWSTR::from_raw(raw_ptr)
}
}
/// Construct `PwStrOwned` from any `str`.
impl<S: AsRef<str> + ?Sized> From<&S> for PwStrOwned {
fn from(value: &S) -> Self {
let mut utf16_bytes = value.as_ref().encode_utf16().collect::<Vec<u16>>();
utf16_bytes.push(0); // the tailing NULL
PwStrOwned(utf16_bytes)
}
}
/// Helper function to replace unsupported characters with whitespace.
///
/// Windows search will error out if it encounters these characters.
///
/// The complete list of unsupported characters is unknown and we don't know how
/// to escape them, so let's replace them.
fn query_string_cleanup(old: &str) -> String {
const UNSUPPORTED_CHAR: [char; 2] = ['\'', '\n'];
// Using len in bytes is ok
let mut chars = Vec::with_capacity(old.len());
for char in old.chars() {
if UNSUPPORTED_CHAR.contains(&char) {
chars.push(' ');
} else {
chars.push(char);
}
}
chars.into_iter().collect()
}
/// Helper function to construct the Windows Search SQL.
///
/// Paging is not natively supported by windows Search SQL, it only supports `size`
/// via the `TOP` keyword ("SELECT TOP {n} {columns}"). The SQL returned by this
/// function will have `{n}` set to `from + size`, then we will manually implement
/// paging.
fn query_sql(query_string: &str, from: usize, size: usize, config: &FileSearchConfig) -> String {
let top_n = from
.checked_add(size)
.expect("[from + size] cannot fit into an [usize]");
// System.ItemUrl is a column that contains the file path
// example: "file:C:/Users/desktop.ini"
//
// System.Search.Rank is the relevance score
let mut sql = format!(
"SELECT TOP {} System.ItemUrl, System.Search.Rank FROM SystemIndex WHERE",
top_n
);
let query_string = query_string_cleanup(query_string);
let search_by_predicate = match config.search_by {
SearchBy::Name => {
// `contains(System.FileName, '{query_string}')` would be faster
// because it uses inverted index, but that's not what we want
// due to the limitation of tokenization. For example, suppose "Coco AI.rs"
// will be tokenized to `["Coco", "AI", "rs"]`, then if users search
// via `Co`, this file won't be returned because term `Co` does not
// exist in the index.
//
// So we use wildcard instead even though it is slower.
format!("(System.FileName LIKE '%{query_string}%')")
}
SearchBy::NameAndContents => {
// Windows File Search does not support searching by file content.
//
// `CONTAINS('query_string')` would search all columns for `query_string`,
// this is the closest solution we have.
format!("((System.FileName LIKE '%{query_string}%') OR CONTAINS('{query_string}'))")
}
};
let search_paths_predicate: Option<String> = {
if config.search_paths.is_empty() {
None
} else {
let mut output = String::from("(");
for (idx, search_path) in config.search_paths.iter().enumerate() {
if idx != 0 {
output.push_str(" OR ");
}
output.push_str("SCOPE = 'file:");
output.push_str(&search_path);
output.push('\'');
}
output.push(')');
Some(output)
}
};
let exclude_paths_predicate: Option<String> = {
if config.exclude_paths.is_empty() {
None
} else {
let mut output = String::from("(");
for (idx, exclude_path) in config.exclude_paths.iter().enumerate() {
if idx != 0 {
output.push_str(" AND ");
}
output.push_str("(NOT SCOPE = 'file:");
output.push_str(&exclude_path);
output.push('\'');
output.push(')');
}
output.push(')');
Some(output)
}
};
let file_types_predicate: Option<String> = {
if config.file_types.is_empty() {
None
} else {
let mut output = String::from("(");
for (idx, file_type) in config.file_types.iter().enumerate() {
if idx != 0 {
output.push_str(" OR ");
}
// NOTE that this column contains a starting dot
output.push_str("System.FileExtension = '.");
output.push_str(&file_type);
output.push('\'');
}
output.push(')');
Some(output)
}
};
sql.push(' ');
sql.push_str(search_by_predicate.as_str());
if let Some(search_paths_predicate) = search_paths_predicate {
sql.push_str(" AND ");
sql.push_str(search_paths_predicate.as_str());
}
if let Some(exclude_paths_predicate) = exclude_paths_predicate {
sql.push_str(" AND ");
sql.push_str(exclude_paths_predicate.as_str());
}
if let Some(file_types_predicate) = file_types_predicate {
sql.push_str(" AND ");
sql.push_str(file_types_predicate.as_str());
}
sql
}
/// Default GUID for Search.CollatorDSO.1
const DBGUID_DEFAULT: GUID = GUID {
data1: 0xc8b521fb,
data2: 0x5cf3,
data3: 0x11ce,
data4: [0xad, 0xe5, 0x00, 0xaa, 0x00, 0x44, 0x77, 0x3d],
};
unsafe fn create_accessor_handle(accessor: &IAccessor, index: usize) -> Result<HACCESSOR, String> {
let bindings = DBBINDING {
iOrdinal: index,
obValue: 0,
obStatus: 0,
obLength: 0,
dwPart: DBPART_VALUE.0 as u32,
dwMemOwner: DBMEMOWNER_CLIENTOWNED.0 as u32,
eParamIO: DBPARAMIO_NOTPARAM.0 as u32,
cbMaxLen: 512,
dwFlags: 0,
wType: DBTYPE_WSTR.0 as u16,
bPrecision: 0,
bScale: 0,
..Default::default()
};
let mut status = 0;
let mut accessor_handle = HACCESSOR::default();
unsafe {
accessor
.CreateAccessor(
DBACCESSOR_ROWDATA.0 as u32,
1,
&bindings,
0,
&mut accessor_handle,
Some(&mut status),
)
.map_err(|e| e.to_string())?;
}
Ok(accessor_handle)
}
fn create_db_initialize() -> Result<IDBInitialize, String> {
unsafe {
let data_init: IDataInitialize =
CoCreateInstance(&MSDAINITIALIZE, None, CLSCTX_INPROC_SERVER)
.map_err(|e| e.to_string())?;
let mut unknown: Option<IUnknown> = None;
data_init
.GetDataSource(
None,
CLSCTX_INPROC_SERVER.0,
w!("provider=Search.CollatorDSO.1;EXTENDED PROPERTIES=\"Application=Windows\""),
&IDBInitialize::IID,
&mut unknown as *mut _ as *mut _,
)
.map_err(|e| e.to_string())?;
Ok(unknown.unwrap().cast().map_err(|e| e.to_string())?)
}
}
fn create_command(db_init: IDBInitialize) -> Result<ICommandText, String> {
unsafe {
let db_create_session: IDBCreateSession = db_init.cast().map_err(|e| e.to_string())?;
let session: IUnknown = db_create_session
.CreateSession(None, &IUnknown::IID)
.map_err(|e| e.to_string())?;
let db_create_command: IDBCreateCommand = session.cast().map_err(|e| e.to_string())?;
Ok(db_create_command
.CreateCommand(None, &ICommand::IID)
.map_err(|e| e.to_string())?
.cast()
.map_err(|e| e.to_string())?)
}
}
fn execute_windows_search_sql(sql_query: &str) -> Result<Vec<(String, String)>, String> {
unsafe {
let mut pwstr_owned_sql = PwStrOwned::from(sql_query);
// SAFETY: pwstr_owned_sql will live for the whole lifetime of this function.
let sql_query = pwstr_owned_sql.as_pwstr();
let db_init = create_db_initialize()?;
db_init.Initialize().map_err(|e| e.to_string())?;
let command = create_command(db_init)?;
// Set the command text
command
.SetCommandText(&DBGUID_DEFAULT, sql_query)
.map_err(|e| e.to_string())?;
// Execute the command
let mut rowset: Option<IRowset> = None;
command
.Execute(
None,
&IRowset::IID,
None,
None,
Some(&mut rowset as *mut _ as *mut _),
)
.map_err(|e| e.to_string())?;
let rowset = rowset.ok_or_else(|| {
format!(
"No rowset returned for query: {}",
// SAFETY: the raw pointer is not dangling
sql_query
.to_string()
.expect("the conversion should work as `sql_query` was created from a String",)
)
})?;
let accessor: IAccessor = rowset
.cast()
.map_err(|e| format!("Failed to cast to IAccessor: {}", e.to_string()))?;
let mut output = Vec::new();
let mut count = 0;
loop {
let mut rows_fetched = 0;
let mut row_handles = [std::ptr::null_mut(); 1];
let result = rowset.GetNextRows(
DB_NULL_HCHAPTER as usize,
0,
&mut rows_fetched,
&mut row_handles,
);
if result.is_err() {
break;
}
if rows_fetched == 0 {
break;
}
let mut data = Vec::new();
for i in 0..2 {
let mut item_name = [0u16; 512];
let accessor_handle = create_accessor_handle(&accessor, i + 1)?;
rowset
.GetData(
*row_handles[0],
accessor_handle,
item_name.as_mut_ptr() as *mut _,
)
.map_err(|e| {
format!(
"Failed to get data at count {}, index {}: {}",
count,
i,
e.to_string()
)
})?;
let name = String::from_utf16_lossy(&item_name);
// Remove null characters
data.push(name.trim_end_matches('\u{0000}').to_string());
accessor
.ReleaseAccessor(accessor_handle, None)
.map_err(|e| {
format!(
"Failed to release accessor at count {}, index {}: {}",
count,
i,
e.to_string()
)
})?;
}
output.push((data[0].clone(), data[1].clone()));
count += 1;
rowset
.ReleaseRows(
1,
row_handles[0],
std::ptr::null_mut(),
std::ptr::null_mut(),
std::ptr::null_mut(),
)
.map_err(|e| {
format!(
"Failed to release rows at count {}: {}",
count,
e.to_string()
)
})?;
}
Ok(output)
}
}
pub(crate) async fn hits(
query_string: &str,
from: usize,
size: usize,
config: &FileSearchConfig,
) -> Result<Vec<(Document, f64)>, String> {
let sql = query_sql(query_string, from, size, config);
unsafe { OleInitialize(None).map_err(|e| e.to_string())? };
let result = execute_windows_search_sql(&sql)?;
unsafe { OleUninitialize() };
// .take(size) is not needed as `result` will contain `from+size` files at most
let result_with_paging = result.into_iter().skip(from);
// result_with_paging won't contain more than `size` entries
let mut hits = Vec::with_capacity(size);
const ITEM_URL_PREFIX: &str = "file:";
const ITEM_URL_PREFIX_LEN: usize = ITEM_URL_PREFIX.len();
for (item_url, score_str) in result_with_paging {
// path returned from Windows Search contains a prefix, we need to trim it.
//
// "file:C:/Users/desktop.ini" => "C:/Users/desktop.ini"
let file_path = &item_url[ITEM_URL_PREFIX_LEN..];
let icon = get_file_icon(file_path.to_string()).await;
let file_path_of_type_path = camino::Utf8Path::new(&file_path);
let r#where = file_path_of_type_path
.parent()
.unwrap_or_else(|| {
panic!(
"expect path [{}] to have a parent, but it does not",
file_path
);
})
.to_string();
let file_name = file_path_of_type_path.file_name().unwrap_or_else(|| {
panic!(
"expect path [{}] to have a file name, but it does not",
file_path
);
});
let on_opened = OnOpened::Document {
url: file_path.to_string(),
};
let doc = Document {
id: file_path.to_string(),
title: Some(file_name.to_string()),
source: Some(DataSourceReference {
r#type: Some(LOCAL_QUERY_SOURCE_TYPE.into()),
name: Some(EXTENSION_ID.into()),
id: Some(EXTENSION_ID.into()),
icon: Some(String::from("font_Filesearch")),
}),
category: Some(r#where),
on_opened: Some(on_opened),
url: Some(file_path.into()),
icon: Some(icon.to_string()),
..Default::default()
};
let score: f64 = score_str.parse().expect(
"System.Search.Rank should be in range [0, 1000], which should be valid for [f64]",
);
hits.push((doc, score));
}
Ok(hits)
}
// Skip these tests in our CI, they fail with the following error
// "SQL is invalid: "0x80041820""
//
// I have no idea about the underlying root cause
#[cfg(all(test, not(ci)))]
mod test_windows_search {
use super::*;
/// Helper function for ensuring `sql` is valid SQL by actually executing it.
fn ensure_it_is_valid_sql(sql: &str) {
unsafe { OleInitialize(None).unwrap() };
execute_windows_search_sql(&sql).expect("SQL is invalid");
unsafe { OleUninitialize() };
}
#[test]
fn test_query_sql_empty_config_search_by_name() {
let config = FileSearchConfig {
search_paths: Vec::new(),
exclude_paths: Vec::new(),
file_types: Vec::new(),
search_by: SearchBy::Name,
};
let sql = query_sql("coco", 0, 10, &config);
assert_eq!(
sql,
"SELECT TOP 10 System.ItemUrl, System.Search.Rank FROM SystemIndex WHERE (System.FileName LIKE '%coco%')"
);
ensure_it_is_valid_sql(&sql);
}
#[test]
fn test_query_sql_empty_config_search_by_name_and_content() {
let config = FileSearchConfig {
search_paths: Vec::new(),
exclude_paths: Vec::new(),
file_types: Vec::new(),
search_by: SearchBy::NameAndContents,
};
let sql = query_sql("coco", 0, 10, &config);
assert_eq!(
sql,
"SELECT TOP 10 System.ItemUrl, System.Search.Rank FROM SystemIndex WHERE ((System.FileName LIKE '%coco%') OR CONTAINS('coco'))"
);
ensure_it_is_valid_sql(&sql);
}
#[test]
fn test_query_sql_with_search_paths() {
let config = FileSearchConfig {
search_paths: vec!["C:/Users/".into()],
exclude_paths: Vec::new(),
file_types: Vec::new(),
search_by: SearchBy::Name,
};
let sql = query_sql("coco", 0, 10, &config);
assert_eq!(
sql,
"SELECT TOP 10 System.ItemUrl, System.Search.Rank FROM SystemIndex WHERE (System.FileName LIKE '%coco%') AND (SCOPE = 'file:C:/Users/')"
);
ensure_it_is_valid_sql(&sql);
}
#[test]
fn test_query_sql_with_multiple_search_paths() {
let config = FileSearchConfig {
search_paths: vec![
"C:/Users/".into(),
"D:/Projects/".into(),
"E:/Documents/".into(),
],
exclude_paths: Vec::new(),
file_types: Vec::new(),
search_by: SearchBy::Name,
};
let sql = query_sql("test", 0, 5, &config);
assert_eq!(
sql,
"SELECT TOP 5 System.ItemUrl, System.Search.Rank FROM SystemIndex WHERE (System.FileName LIKE '%test%') AND (SCOPE = 'file:C:/Users/' OR SCOPE = 'file:D:/Projects/' OR SCOPE = 'file:E:/Documents/')"
);
ensure_it_is_valid_sql(&sql);
}
#[test]
fn test_query_sql_with_exclude_paths() {
let config = FileSearchConfig {
search_paths: Vec::new(),
exclude_paths: vec!["C:/Windows/".into()],
file_types: Vec::new(),
search_by: SearchBy::Name,
};
let sql = query_sql("file", 0, 20, &config);
assert_eq!(
sql,
"SELECT TOP 20 System.ItemUrl, System.Search.Rank FROM SystemIndex WHERE (System.FileName LIKE '%file%') AND ((NOT SCOPE = 'file:C:/Windows/'))"
);
ensure_it_is_valid_sql(&sql);
}
#[test]
fn test_query_sql_with_multiple_exclude_paths() {
let config = FileSearchConfig {
search_paths: Vec::new(),
exclude_paths: vec!["C:/Windows/".into(), "C:/System/".into(), "C:/Temp/".into()],
file_types: Vec::new(),
search_by: SearchBy::Name,
};
let sql = query_sql("data", 5, 15, &config);
assert_eq!(
sql,
"SELECT TOP 20 System.ItemUrl, System.Search.Rank FROM SystemIndex WHERE (System.FileName LIKE '%data%') AND ((NOT SCOPE = 'file:C:/Windows/') AND (NOT SCOPE = 'file:C:/System/') AND (NOT SCOPE = 'file:C:/Temp/'))"
);
ensure_it_is_valid_sql(&sql);
}
#[test]
fn test_query_sql_with_file_types() {
let config = FileSearchConfig {
search_paths: Vec::new(),
exclude_paths: Vec::new(),
file_types: vec!["txt".into()],
search_by: SearchBy::Name,
};
let sql = query_sql("readme", 0, 10, &config);
assert_eq!(
sql,
"SELECT TOP 10 System.ItemUrl, System.Search.Rank FROM SystemIndex WHERE (System.FileName LIKE '%readme%') AND (System.FileExtension = '.txt')"
);
ensure_it_is_valid_sql(&sql);
}
#[test]
fn test_query_sql_with_multiple_file_types() {
let config = FileSearchConfig {
search_paths: Vec::new(),
exclude_paths: Vec::new(),
file_types: vec!["rs".into(), "toml".into(), "md".into(), "json".into()],
search_by: SearchBy::Name,
};
let sql = query_sql("config", 0, 50, &config);
assert_eq!(
sql,
"SELECT TOP 50 System.ItemUrl, System.Search.Rank FROM SystemIndex WHERE (System.FileName LIKE '%config%') AND (System.FileExtension = '.rs' OR System.FileExtension = '.toml' OR System.FileExtension = '.md' OR System.FileExtension = '.json')"
);
ensure_it_is_valid_sql(&sql);
}
#[test]
fn test_query_sql_all_fields_combined() {
let config = FileSearchConfig {
search_paths: vec!["C:/Projects/".into(), "D:/Code/".into()],
exclude_paths: vec!["C:/Projects/temp/".into()],
file_types: vec!["rs".into(), "ts".into()],
search_by: SearchBy::Name,
};
let sql = query_sql("main", 10, 25, &config);
assert_eq!(
sql,
"SELECT TOP 35 System.ItemUrl, System.Search.Rank FROM SystemIndex WHERE (System.FileName LIKE '%main%') AND (SCOPE = 'file:C:/Projects/' OR SCOPE = 'file:D:/Code/') AND ((NOT SCOPE = 'file:C:/Projects/temp/')) AND (System.FileExtension = '.rs' OR System.FileExtension = '.ts')"
);
ensure_it_is_valid_sql(&sql);
}
#[test]
fn test_query_sql_with_special_characters() {
let config = FileSearchConfig {
search_paths: vec!["C:/Users/John Doe/".into()],
exclude_paths: Vec::new(),
file_types: vec!["c++".into()],
search_by: SearchBy::Name,
};
let sql = query_sql("hello-world", 0, 10, &config);
assert_eq!(
sql,
"SELECT TOP 10 System.ItemUrl, System.Search.Rank FROM SystemIndex WHERE (System.FileName LIKE '%hello-world%') AND (SCOPE = 'file:C:/Users/John Doe/') AND (System.FileExtension = '.c++')"
);
ensure_it_is_valid_sql(&sql);
}
#[test]
fn test_query_sql_edge_case_large_offset() {
let config = FileSearchConfig {
search_paths: Vec::new(),
exclude_paths: Vec::new(),
file_types: Vec::new(),
search_by: SearchBy::Name,
};
let sql = query_sql("test", 100, 50, &config);
assert_eq!(
sql,
"SELECT TOP 150 System.ItemUrl, System.Search.Rank FROM SystemIndex WHERE (System.FileName LIKE '%test%')"
);
ensure_it_is_valid_sql(&sql);
}
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn test_query_string_cleanup_no_unsupported_chars() {
let input = "hello world";
let result = query_string_cleanup(input);
assert_eq!(result, input);
}
#[test]
fn test_query_string_cleanup_single_quote() {
let input = "don't worry";
let result = query_string_cleanup(input);
assert_eq!(result, "don t worry");
}
#[test]
fn test_query_string_cleanup_newline() {
let input = "line1\nline2";
let result = query_string_cleanup(input);
assert_eq!(result, "line1 line2");
}
#[test]
fn test_query_string_cleanup_both_unsupported_chars() {
let input = "don't\nworry";
let result = query_string_cleanup(input);
assert_eq!(result, "don t worry");
}
#[test]
fn test_query_string_cleanup_multiple_single_quotes() {
let input = "it's a 'test' string";
let result = query_string_cleanup(input);
assert_eq!(result, "it s a test string");
}
#[test]
fn test_query_string_cleanup_multiple_newlines() {
let input = "line1\n\nline2\nline3";
let result = query_string_cleanup(input);
assert_eq!(result, "line1 line2 line3");
}
#[test]
fn test_query_string_cleanup_empty_string() {
let input = "";
let result = query_string_cleanup(input);
assert_eq!(result, input);
}
#[test]
fn test_query_string_cleanup_only_unsupported_chars() {
let input = "'\n'";
let result = query_string_cleanup(input);
assert_eq!(result, " ");
}
#[test]
fn test_query_string_cleanup_unicode_characters() {
let input = "héllo wörld's\nfile";
let result = query_string_cleanup(input);
assert_eq!(result, "héllo wörld s file");
}
#[test]
fn test_query_string_cleanup_special_chars_preserved() {
let input = "test@file#name$with%symbols";
let result = query_string_cleanup(input);
assert_eq!(result, input);
}
}

View File

@@ -0,0 +1,97 @@
pub(crate) mod config;
pub(crate) mod implementation;
use super::super::LOCAL_QUERY_SOURCE_TYPE;
use crate::common::{
error::SearchError,
search::{QueryResponse, QuerySource, SearchQuery},
traits::SearchSource,
};
use async_trait::async_trait;
use config::FileSearchConfig;
use hostname;
use tauri::AppHandle;
pub(crate) const EXTENSION_ID: &str = "File Search";
/// JSON file for this extension.
pub(crate) const PLUGIN_JSON_FILE: &str = r#"
{
"id": "File Search",
"name": "File Search",
"platforms": ["macos", "windows"],
"description": "Search files on your system",
"icon": "font_Filesearch",
"type": "extension"
}
"#;
pub struct FileSearchExtensionSearchSource;
#[async_trait]
impl SearchSource for FileSearchExtensionSearchSource {
fn get_type(&self) -> QuerySource {
QuerySource {
r#type: LOCAL_QUERY_SOURCE_TYPE.into(),
name: hostname::get()
.unwrap_or(EXTENSION_ID.into())
.to_string_lossy()
.into(),
id: EXTENSION_ID.into(),
}
}
async fn search(
&self,
tauri_app_handle: AppHandle,
query: SearchQuery,
) -> Result<QueryResponse, SearchError> {
let Some(query_string) = query.query_strings.get("query") else {
return Ok(QueryResponse {
source: self.get_type(),
hits: Vec::new(),
total_hits: 0,
});
};
let from = usize::try_from(query.from).expect("from too big");
let size = usize::try_from(query.size).expect("size too big");
let query_string = query_string.trim();
if query_string.is_empty() {
return Ok(QueryResponse {
source: self.get_type(),
hits: Vec::new(),
total_hits: 0,
});
}
// Get configuration from tauri store
let config = FileSearchConfig::get(&tauri_app_handle);
// If search paths are empty, then the hit should be empty.
//
// Without this, empty search paths will result in a mdfind that has no `-onlyin`
// option, which will in turn query the whole disk volume.
if config.search_paths.is_empty() {
return Ok(QueryResponse {
source: self.get_type(),
hits: Vec::new(),
total_hits: 0,
});
}
// Execute search in a blocking task
let query_source = self.get_type();
let hits = implementation::hits(&query_string, from, size, &config)
.await
.map_err(SearchError::InternalError)?;
let total_hits = hits.len();
Ok(QueryResponse {
source: query_source,
hits,
total_hits,
})
}
}

View File

@@ -0,0 +1,541 @@
//! Built-in extensions and related stuff.
pub mod ai_overview;
pub mod application;
pub mod calculator;
#[cfg(any(target_os = "macos", target_os = "windows"))]
pub mod file_search;
pub mod pizza_engine_runtime;
pub mod quick_ai_access;
use super::Extension;
use crate::SearchSourceRegistry;
use crate::extension::built_in::application::{set_apps_hotkey, unset_apps_hotkey};
use crate::extension::{
ExtensionBundleIdBorrowed, PLUGIN_JSON_FILE_NAME, alter_extension_json_file,
};
use anyhow::Context;
use std::path::{Path, PathBuf};
use tauri::{AppHandle, Manager};
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",
);
resource_dir.push("built_in_extensions");
resource_dir
}
/// Helper function to load the built-in extension specified by `extension_id`, used
/// in `list_built_in_extensions()`.
///
/// For built-in extensions, users are only allowed to edit these fields:
///
/// 1. alias (if this extension supports alias)
/// 2. hotkey (if this extension supports hotkey)
/// 3. enabled
///
/// If
///
/// 1. The above fields have invalid value
/// 2. Other fields are modified
///
/// we ignore and reset them to the default value.
async fn load_built_in_extension(
built_in_extensions_dir: &Path,
extension_id: &str,
default_plugin_json_file: &str,
) -> Result<Extension, String> {
let mut extension_dir = built_in_extensions_dir.join(extension_id);
let mut default_plugin_json = serde_json::from_str::<Extension>(&default_plugin_json_file).unwrap_or_else( |e| {
panic!("the default extension {} file of built-in extension [{}] cannot be parsed as a valid [struct Extension], error [{}]", PLUGIN_JSON_FILE_NAME, extension_id, e);
});
if !extension_dir.try_exists().map_err(|e| e.to_string())? {
tokio::fs::create_dir_all(extension_dir.as_path())
.await
.map_err(|e| e.to_string())?;
}
let plugin_json_file_path = {
extension_dir.push(PLUGIN_JSON_FILE_NAME);
extension_dir
};
// If the JSON file does not exist, create a file with the default template and return.
if !plugin_json_file_path
.try_exists()
.map_err(|e| e.to_string())?
{
tokio::fs::write(plugin_json_file_path, default_plugin_json_file)
.await
.map_err(|e| e.to_string())?;
return Ok(default_plugin_json);
}
let plugin_json_file_content = tokio::fs::read_to_string(plugin_json_file_path.as_path())
.await
.map_err(|e| e.to_string())?;
let res_plugin_json = serde_json::from_str::<Extension>(&plugin_json_file_content);
let Ok(plugin_json) = res_plugin_json else {
log::warn!(
"user invalidated built-in extension [{}] file, overwriting it with the default template",
extension_id
);
// If the JSON file cannot be parsed as `struct Extension`, overwrite it with the default template and return.
tokio::fs::write(plugin_json_file_path, default_plugin_json_file)
.await
.map_err(|e| e.to_string())?;
return Ok(default_plugin_json);
};
// Users are only allowed to edit the below fields
// 1. alias (if this extension supports alias)
// 2. hotkey (if this extension supports hotkey)
// 3. enabled
// so we ignore all other fields.
let alias = if default_plugin_json.supports_alias_hotkey() {
plugin_json.alias.clone()
} else {
None
};
let hotkey = if default_plugin_json.supports_alias_hotkey() {
plugin_json.hotkey.clone()
} else {
None
};
let enabled = plugin_json.enabled;
default_plugin_json.alias = alias;
default_plugin_json.hotkey = hotkey;
default_plugin_json.enabled = enabled;
let final_plugin_json_file_content = serde_json::to_string_pretty(&default_plugin_json)
.expect("failed to serialize `struct Extension`");
tokio::fs::write(plugin_json_file_path, final_plugin_json_file_content)
.await
.map_err(|e| e.to_string())?;
Ok(default_plugin_json)
}
/// Return the built-in extension list.
///
/// Will create extension files when they are not found.
///
/// Users may put extension files in the built-in extension directory, but
/// we do not care and will ignore them.
///
/// We only read alias/hotkey/enabled from the JSON file, we have ensured that if
/// alias/hotkey is not supported, then it will be `None`. Besides that, no further
/// validation is needed because nothing could go wrong.
pub(crate) async fn list_built_in_extensions(
tauri_app_handle: &AppHandle,
) -> Result<Vec<Extension>, String> {
let dir = get_built_in_extension_directory(tauri_app_handle);
let mut built_in_extensions = Vec::new();
built_in_extensions.push(
load_built_in_extension(
&dir,
application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME,
application::PLUGIN_JSON_FILE,
)
.await?,
);
built_in_extensions.push(
load_built_in_extension(
&dir,
calculator::DATA_SOURCE_ID,
calculator::PLUGIN_JSON_FILE,
)
.await?,
);
built_in_extensions.push(
load_built_in_extension(
&dir,
ai_overview::EXTENSION_ID,
ai_overview::PLUGIN_JSON_FILE,
)
.await?,
);
built_in_extensions.push(
load_built_in_extension(
&dir,
quick_ai_access::EXTENSION_ID,
quick_ai_access::PLUGIN_JSON_FILE,
)
.await?,
);
cfg_if::cfg_if! {
if #[cfg(any(target_os = "macos", target_os = "windows"))] {
built_in_extensions.push(
load_built_in_extension(
&dir,
file_search::EXTENSION_ID,
file_search::PLUGIN_JSON_FILE,
)
.await?,
);
}
}
Ok(built_in_extensions)
}
pub(super) async fn init_built_in_extension(
tauri_app_handle: &AppHandle,
extension: &Extension,
search_source_registry: &SearchSourceRegistry,
) -> Result<(), String> {
log::trace!("initializing built-in extensions [{}]", extension.id);
if extension.id == application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME {
search_source_registry
.register_source(application::ApplicationSearchSource)
.await;
set_apps_hotkey(&tauri_app_handle)?;
log::debug!("built-in extension [{}] initialized", extension.id);
}
if extension.id == calculator::DATA_SOURCE_ID {
let calculator_search = calculator::CalculatorSource::new(2000f64);
search_source_registry
.register_source(calculator_search)
.await;
log::debug!("built-in extension [{}] initialized", extension.id);
}
cfg_if::cfg_if! {
if #[cfg(any(target_os = "macos", target_os = "windows"))] {
if extension.id == file_search::EXTENSION_ID {
let file_system_search = file_search::FileSearchExtensionSearchSource;
search_source_registry
.register_source(file_system_search)
.await;
log::debug!("built-in extension [{}] initialized", extension.id);
}
}
}
Ok(())
}
pub(crate) fn is_extension_built_in(bundle_id: &ExtensionBundleIdBorrowed<'_>) -> bool {
bundle_id.developer.is_none()
}
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>();
let update_extension = |extension: &mut Extension| -> Result<(), String> {
extension.enabled = true;
Ok(())
};
if bundle_id.extension_id == application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME
&& bundle_id.sub_extension_id.is_none()
{
search_source_registry_tauri_state
.register_source(application::ApplicationSearchSource)
.await;
set_apps_hotkey(tauri_app_handle)?;
alter_extension_json_file(
&get_built_in_extension_directory(tauri_app_handle),
bundle_id,
update_extension,
)?;
return Ok(());
}
// Check if this is an application
if bundle_id.extension_id == application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME
&& bundle_id.sub_extension_id.is_some()
{
let app_path = bundle_id.sub_extension_id.expect("just checked it is Some");
application::enable_app_search(tauri_app_handle, app_path)?;
return Ok(());
}
if bundle_id.extension_id == calculator::DATA_SOURCE_ID {
let calculator_search = calculator::CalculatorSource::new(2000f64);
search_source_registry_tauri_state
.register_source(calculator_search)
.await;
alter_extension_json_file(
&get_built_in_extension_directory(tauri_app_handle),
bundle_id,
update_extension,
)?;
return Ok(());
}
if bundle_id.extension_id == quick_ai_access::EXTENSION_ID {
alter_extension_json_file(
&get_built_in_extension_directory(tauri_app_handle),
bundle_id,
update_extension,
)?;
return Ok(());
}
if bundle_id.extension_id == ai_overview::EXTENSION_ID {
alter_extension_json_file(
&get_built_in_extension_directory(tauri_app_handle),
bundle_id,
update_extension,
)?;
return Ok(());
}
cfg_if::cfg_if! {
if #[cfg(any(target_os = "macos", target_os = "windows"))] {
if bundle_id.extension_id == file_search::EXTENSION_ID {
let file_system_search = file_search::FileSearchExtensionSearchSource;
search_source_registry_tauri_state
.register_source(file_system_search)
.await;
alter_extension_json_file(
&get_built_in_extension_directory(tauri_app_handle),
bundle_id,
update_extension,
)?;
return Ok(());
}
}
}
Ok(())
}
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>();
let update_extension = |extension: &mut Extension| -> Result<(), String> {
extension.enabled = false;
Ok(())
};
if bundle_id.extension_id == application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME
&& bundle_id.sub_extension_id.is_none()
{
search_source_registry_tauri_state
.remove_source(bundle_id.extension_id)
.await;
unset_apps_hotkey(tauri_app_handle)?;
alter_extension_json_file(
&get_built_in_extension_directory(tauri_app_handle),
bundle_id,
update_extension,
)?;
return Ok(());
}
// Check if this is an application
if bundle_id.extension_id == application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME
&& bundle_id.sub_extension_id.is_some()
{
let app_path = bundle_id.sub_extension_id.expect("just checked it is Some");
application::disable_app_search(tauri_app_handle, app_path)?;
return Ok(());
}
if bundle_id.extension_id == calculator::DATA_SOURCE_ID {
search_source_registry_tauri_state
.remove_source(bundle_id.extension_id)
.await;
alter_extension_json_file(
&get_built_in_extension_directory(tauri_app_handle),
bundle_id,
update_extension,
)?;
return Ok(());
}
if bundle_id.extension_id == quick_ai_access::EXTENSION_ID {
alter_extension_json_file(
&get_built_in_extension_directory(tauri_app_handle),
bundle_id,
update_extension,
)?;
return Ok(());
}
if bundle_id.extension_id == ai_overview::EXTENSION_ID {
alter_extension_json_file(
&get_built_in_extension_directory(tauri_app_handle),
bundle_id,
update_extension,
)?;
return Ok(());
}
cfg_if::cfg_if! {
if #[cfg(any(target_os = "macos", target_os = "windows"))] {
if bundle_id.extension_id == file_search::EXTENSION_ID {
search_source_registry_tauri_state
.remove_source(bundle_id.extension_id)
.await;
alter_extension_json_file(
&get_built_in_extension_directory(tauri_app_handle),
bundle_id,
update_extension,
)?;
return Ok(());
}
}
}
Ok(())
}
pub(crate) fn set_built_in_extension_alias(
tauri_app_handle: &AppHandle,
bundle_id: &ExtensionBundleIdBorrowed<'_>,
alias: &str,
) {
if bundle_id.extension_id == application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME {
if let Some(app_path) = bundle_id.sub_extension_id {
application::set_app_alias(tauri_app_handle, app_path, alias);
}
}
}
pub(crate) fn register_built_in_extension_hotkey(
tauri_app_handle: &AppHandle,
bundle_id: &ExtensionBundleIdBorrowed<'_>,
hotkey: &str,
) -> Result<(), String> {
if bundle_id.extension_id == application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME {
if let Some(app_path) = bundle_id.sub_extension_id {
application::register_app_hotkey(&tauri_app_handle, app_path, hotkey)?;
}
}
Ok(())
}
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 {
if let Some(app_path) = bundle_id.sub_extension_id {
application::unregister_app_hotkey(&tauri_app_handle, app_path)?;
}
}
Ok(())
}
fn split_extension_id(extension_id: &str) -> (&str, Option<&str>) {
match extension_id.find('.') {
Some(idx) => (&extension_id[..idx], Some(&extension_id[idx + 1..])),
None => (extension_id, None),
}
}
fn load_extension_from_json_file(
extension_directory: &Path,
extension_id: &str,
) -> Result<Extension, String> {
let (parent_extension_id, _opt_sub_extension_id) = split_extension_id(extension_id);
let json_file_path = {
let mut extension_directory_path = extension_directory.join(parent_extension_id);
extension_directory_path.push(PLUGIN_JSON_FILE_NAME);
extension_directory_path
};
let mut extension = serde_json::from_reader::<_, Extension>(
std::fs::File::open(&json_file_path)
.with_context(|| {
format!(
"the [{}] file for extension [{}] is missing or broken",
PLUGIN_JSON_FILE_NAME, parent_extension_id
)
})
.map_err(|e| e.to_string())?,
)
.map_err(|e| e.to_string())?;
super::canonicalize_relative_icon_path(extension_directory, &mut extension)?;
Ok(extension)
}
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>();
if bundle_id.extension_id == application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME
&& bundle_id.sub_extension_id.is_none()
{
return Ok(search_source_registry_tauri_state
.get_source(bundle_id.extension_id)
.await
.is_some());
}
// Check if this is an application
if bundle_id.extension_id == application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME {
if let Some(app_path) = bundle_id.sub_extension_id {
return Ok(application::is_app_search_enabled(app_path));
}
}
if bundle_id.extension_id == calculator::DATA_SOURCE_ID {
return Ok(search_source_registry_tauri_state
.get_source(bundle_id.extension_id)
.await
.is_some());
}
if bundle_id.extension_id == quick_ai_access::EXTENSION_ID {
let extension = load_extension_from_json_file(
&get_built_in_extension_directory(tauri_app_handle),
bundle_id.extension_id,
)?;
return Ok(extension.enabled);
}
if bundle_id.extension_id == ai_overview::EXTENSION_ID {
let extension = load_extension_from_json_file(
&get_built_in_extension_directory(tauri_app_handle),
bundle_id.extension_id,
)?;
return Ok(extension.enabled);
}
cfg_if::cfg_if! {
if #[cfg(any(target_os = "macos", target_os = "windows"))] {
if bundle_id.extension_id == file_search::EXTENSION_ID
&& bundle_id.sub_extension_id.is_none()
{
return Ok(search_source_registry_tauri_state
.get_source(bundle_id.extension_id)
.await
.is_some());
}
}
}
unreachable!("extension [{:?}] is not a built-in extension", bundle_id)
}

View File

@@ -0,0 +1,76 @@
//! We use Pizza Engine to index applications and local files. The engine will be
//! run in the thread/runtime defined in this file.
//!
//! # Why such a thread/runtime is needed
//!
//! Generally, Tokio async runtime requires all the async tasks running on it to be
//! `Send` and `Sync`, but the async tasks created by Pizza Engine are not,
//! which forces us to create a dedicated thread/runtime to execute them.
use std::any::Any;
use std::collections::HashMap;
use std::collections::hash_map::Entry;
use std::sync::OnceLock;
pub(crate) trait SearchSourceState {
#[cfg_attr(not(feature = "use_pizza_engine"), allow(unused))]
fn as_mut_any(&mut self) -> &mut dyn Any;
}
#[async_trait::async_trait(?Send)]
pub(crate) trait Task: Send + Sync {
fn search_source_id(&self) -> &'static str;
async fn exec(&mut self, state: &mut Option<Box<dyn SearchSourceState>>);
}
pub(crate) static RUNTIME_TX: OnceLock<tokio::sync::mpsc::UnboundedSender<Box<dyn Task>>> =
OnceLock::new();
/// This function blocks until the runtime thread is ready for accepting tasks.
pub(crate) async fn start_pizza_engine_runtime() {
const THREAD_NAME: &str = "Pizza engine runtime thread";
log::trace!("starting Pizza engine runtime");
let (engine_start_signal_tx, engine_start_signal_rx) = tokio::sync::oneshot::channel();
std::thread::Builder::new()
.name(THREAD_NAME.into())
.spawn(move || {
let rt = tokio::runtime::Runtime::new().unwrap();
let main = async {
let mut states: HashMap<String, Option<Box<dyn SearchSourceState>>> =
HashMap::new();
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
RUNTIME_TX.set(tx).unwrap();
engine_start_signal_tx
.send(())
.expect("engine_start_signal_rx dropped");
while let Some(mut task) = rx.recv().await {
let opt_search_source_state = match states.entry(task.search_source_id().into())
{
Entry::Occupied(o) => o.into_mut(),
Entry::Vacant(v) => v.insert(None),
};
task.exec(opt_search_source_state).await;
}
};
rt.block_on(main);
})
.unwrap_or_else(|e| {
panic!(
"failed to start thread [{}] due to error [{}]",
THREAD_NAME, e
);
});
engine_start_signal_rx
.await
.expect("engine_start_signal_tx dropped, the runtime thread could be dead");
log::trace!("Pizza engine runtime started");
}

View File

@@ -0,0 +1,12 @@
pub(super) const EXTENSION_ID: &str = "QuickAIAccess";
pub(crate) const PLUGIN_JSON_FILE: &str = r#"
{
"id": "QuickAIAccess",
"name": "Quick AI Access",
"description": "...",
"icon": "font_a-QuickAIAccess",
"type": "ai_extension",
"enabled": true
}
"#;

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

@@ -0,0 +1,403 @@
//! Extension store related stuff.
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;
use crate::common::search::QueryResponse;
use crate::common::search::QuerySource;
use crate::common::search::SearchQuery;
use crate::common::traits::SearchSource;
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;
use std::io::Read;
use tauri::AppHandle;
const DATA_SOURCE_ID: &str = "Extension Store";
pub(crate) struct ExtensionStore;
#[async_trait]
impl SearchSource for ExtensionStore {
fn get_type(&self) -> QuerySource {
QuerySource {
r#type: LOCAL_QUERY_SOURCE_TYPE.into(),
name: hostname::get()
.unwrap_or(DATA_SOURCE_ID.into())
.to_string_lossy()
.into(),
id: DATA_SOURCE_ID.into(),
}
}
async fn search(
&self,
_tauri_app_handle: AppHandle,
query: SearchQuery,
) -> Result<QueryResponse, SearchError> {
const SCORE: f64 = 2000.0;
let Some(query_string) = query.query_strings.get("query") else {
return Ok(QueryResponse {
source: self.get_type(),
hits: Vec::new(),
total_hits: 0,
});
};
let lowercase_query_string = query_string.to_lowercase();
let expected_str = "extension store";
if expected_str.contains(&lowercase_query_string) {
let doc = Document {
id: DATA_SOURCE_ID.to_string(),
category: Some(DATA_SOURCE_ID.to_string()),
title: Some(DATA_SOURCE_ID.to_string()),
icon: Some("font_Store".to_string()),
source: Some(DataSourceReference {
r#type: Some(LOCAL_QUERY_SOURCE_TYPE.into()),
name: Some(DATA_SOURCE_ID.into()),
id: Some(DATA_SOURCE_ID.into()),
icon: Some("font_Store".to_string()),
}),
..Default::default()
};
Ok(QueryResponse {
source: self.get_type(),
hits: vec![(doc, SCORE)],
total_hits: 1,
})
} else {
Ok(QueryResponse {
source: self.get_type(),
hits: Vec::new(),
total_hits: 0,
})
}
}
}
#[tauri::command]
pub(crate) async fn search_extension(
query_params: Option<Vec<String>>,
) -> Result<Vec<Json>, String> {
let response = HttpClient::get(
"default_coco_server",
"store/extension/_search",
query_params,
)
.await
.map_err(|e| format!("Failed to send request: {:?}", e))?;
// The response of a ES style search request
let mut response: JsonObject<String, Json> = response
.json()
.await
.map_err(|e| format!("Failed to parse response: {:?}", e))?;
let hits_json = response
.remove("hits")
.expect("the JSON response should contain field [hits]");
let mut hits = match hits_json {
Json::Object(obj) => obj,
_ => panic!(
"field [hits] should be a JSON object, but it is not, value: [{}]",
hits_json
),
};
let Some(hits_hits_json) = hits.remove("hits") else {
return Ok(Vec::new());
};
let hits_hits = match hits_hits_json {
Json::Array(arr) => arr,
_ => panic!(
"field [hits.hits] should be an array, but it is not, value: [{}]",
hits_hits_json
),
};
let mut extensions = Vec::with_capacity(hits_hits.len());
for hit in hits_hits {
let mut hit_obj = match hit {
Json::Object(obj) => obj,
_ => panic!(
"each hit in [hits.hits] should be a JSON object, but it is not, value: [{}]",
hit
),
};
let source = hit_obj
.remove("_source")
.expect("each hit should contain field [_source]");
let mut source_obj = match source {
Json::Object(obj) => obj,
_ => panic!(
"field [_source] should be a JSON object, but it is not, value: [{}]",
source
),
};
let developer_id = source_obj
.get("developer")
.and_then(|dev| dev.get("id"))
.and_then(|id| id.as_str())
.expect("developer.id should exist");
let extension_id = source_obj
.get("id")
.and_then(|id| id.as_str())
.expect("extension id should exist");
let installed = is_extension_installed(developer_id, extension_id).await;
source_obj.insert("installed".to_string(), Json::Bool(installed));
extensions.push(Json::Object(source_obj));
}
Ok(extensions)
}
#[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]
pub(crate) async fn install_extension_from_store(
tauri_app_handle: AppHandle,
id: String,
) -> Result<(), String> {
let path = format!("store/extension/{}/_download", id);
let response = HttpClient::get("default_coco_server", &path, None)
.await
.map_err(|e| format!("Failed to download extension: {}", e))?;
if response.status() == StatusCode::NOT_FOUND {
return Err(format!("extension [{}] not found", id));
}
let bytes = response
.bytes()
.await
.map_err(|e| format!("Failed to read response bytes: {}", e))?;
let cursor = std::io::Cursor::new(bytes);
let mut archive =
zip::ZipArchive::new(cursor).map_err(|e| format!("Failed to read zip archive: {}", e))?;
// The plugin.json sent from the server does not conform to our `struct Extension` definition:
//
// 1. Its `developer` field is a JSON object, but we need a string
// 2. sub-extensions won't have their `id` fields set
//
// we need to correct it
let mut plugin_json = archive
.by_name(PLUGIN_JSON_FILE_NAME)
.map_err(|e| e.to_string())?;
let mut plugin_json_content = String::new();
std::io::Read::read_to_string(&mut plugin_json, &mut plugin_json_content)
.map_err(|e| e.to_string())?;
let mut extension: Json = serde_json::from_str(&plugin_json_content)
.map_err(|e| format!("Failed to parse plugin.json: {}", e))?;
let mut_ref_to_developer_object: &mut Json = extension
.as_object_mut()
.expect("plugin.json should be an object")
.get_mut("developer")
.expect("plugin.json should contain field [developer]");
let developer_id = mut_ref_to_developer_object
.get("id")
.expect("plugin.json should contain [developer.id]")
.as_str()
.expect("plugin.json field [developer.id] should be a string");
*mut_ref_to_developer_object = Json::String(developer_id.into());
// Set IDs for sub-extensions (commands, quicklinks, scripts)
let mut counter = 0;
// Helper function to set IDs for array fields
fn set_ids_for_field(extension: &mut Json, field_name: &str, counter: &mut i32) {
if let Some(field) = extension.as_object_mut().unwrap().get_mut(field_name) {
if let Some(array) = field.as_array_mut() {
for item in array {
if let Some(item_obj) = item.as_object_mut() {
if !item_obj.contains_key("id") {
item_obj.insert("id".to_string(), Json::String(counter.to_string()));
*counter += 1;
}
}
}
}
}
}
set_ids_for_field(&mut extension, "commands", &mut counter);
set_ids_for_field(&mut extension, "quicklinks", &mut counter);
set_ids_for_field(&mut extension, "scripts", &mut counter);
// Now the extension JSON is valid
let mut extension: Extension = serde_json::from_value(extension).unwrap_or_else(|e| {
panic!(
"cannot parse plugin.json as struct Extension, error [{:?}]",
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 extension_id = extension.id.clone();
let extension_directory = {
let mut path = get_third_party_extension_directory(&tauri_app_handle);
path.push(developer_id);
path.push(extension_id.as_str());
path
};
tokio::fs::create_dir_all(extension_directory.as_path())
.await
.map_err(|e| e.to_string())?;
// Extract all files except plugin.json
for i in 0..archive.len() {
let mut zip_file = archive.by_index(i).map_err(|e| e.to_string())?;
// `.name()` is safe to use in our cases, the cases listed in the below
// page won't happen to us.
//
// https://docs.rs/zip/4.2.0/zip/read/struct.ZipFile.html#method.name
//
// Example names:
//
// * `assets/icon.png`
// * `assets/screenshot.png`
// * `plugin.json`
//
// Yes, the `assets` directory is not a part of it.
let zip_file_name = zip_file.name();
// Skip the plugin.json file as we'll create it from the extension variable
if zip_file_name == PLUGIN_JSON_FILE_NAME {
continue;
}
let dest_file_path = extension_directory.join(zip_file_name);
// For cases like `assets/xxx.png`
if let Some(parent_dir) = dest_file_path.parent()
&& !parent_dir.exists()
{
tokio::fs::create_dir_all(parent_dir)
.await
.map_err(|e| e.to_string())?;
}
let mut dest_file = tokio::fs::File::create(&dest_file_path)
.await
.map_err(|e| e.to_string())?;
let mut src_bytes = Vec::with_capacity(
zip_file
.size()
.try_into()
.expect("we won't have a extension file that is bigger than 4GiB"),
);
zip_file
.read_to_end(&mut src_bytes)
.map_err(|e| e.to_string())?;
tokio::io::copy(&mut src_bytes.as_slice(), &mut dest_file)
.await
.map_err(|e| e.to_string())?;
}
// Create plugin.json from the extension variable
let plugin_json_path = extension_directory.join(PLUGIN_JSON_FILE_NAME);
let extension_json = serde_json::to_string_pretty(&extension).map_err(|e| e.to_string())?;
tokio::fs::write(&plugin_json_path, extension_json)
.await
.map_err(|e| e.to_string())?;
// Turn it into an absolute path if it is a valid relative path because frontend code need this.
canonicalize_relative_icon_path(&extension_directory, &mut extension)?;
third_party_ext_list_write_lock.push(extension);
Ok(())
}

1028
src-tauri/src/extension/third_party/mod.rs vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,31 +1,24 @@
mod assistant;
mod autostart;
mod common;
mod local;
mod extension;
mod search;
mod server;
mod settings;
mod setup;
mod shortcut;
mod util;
use crate::common::register::SearchSourceRegistry;
// use crate::common::traits::SearchSource;
use crate::common::{MAIN_WINDOW_LABEL, SETTINGS_WINDOW_LABEL};
use crate::server::search::CocoSearchSource;
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, enable_autostart};
use autostart::change_autostart;
use lazy_static::lazy_static;
use reqwest::Client;
use std::path::PathBuf;
use std::sync::Mutex;
#[cfg(target_os = "macos")]
use tauri::ActivationPolicy;
use tauri::{
AppHandle, Emitter, Manager, PhysicalPosition, Runtime, State, WebviewWindow, Window,
WindowEvent,
};
use std::sync::OnceLock;
use tauri::plugin::TauriPlugin;
use tauri::{AppHandle, Emitter, Manager, PhysicalPosition, WebviewWindow, WindowEvent};
use tauri_plugin_autostart::MacosLauncher;
use tokio::runtime::Runtime as RT;
/// Tauri store name
pub(crate) const COCO_TAURI_STORE: &str = "coco_tauri_store";
@@ -33,9 +26,18 @@ pub(crate) const COCO_TAURI_STORE: &str = "coco_tauri_store";
lazy_static! {
static ref PREVIOUS_MONITOR_NAME: Mutex<Option<String>> = Mutex::new(None);
}
/// To allow us to access tauri's `AppHandle` when its context is inaccessible,
/// store it globally. It will be set in `init()`.
///
/// # WARNING
///
/// You may find this work, but the usage is discouraged and should be generally
/// avoided. If you do need it, always be careful that it may not be set() when
/// you access it.
pub(crate) static GLOBAL_TAURI_APP_HANDLE: OnceLock<AppHandle> = OnceLock::new();
#[tauri::command]
fn change_window_height(handle: AppHandle, height: u32) {
async fn change_window_height(handle: AppHandle, height: u32) {
let window: WebviewWindow = handle.get_webview_window(MAIN_WINDOW_LABEL).unwrap();
let mut size = window.outer_size().unwrap();
@@ -45,10 +47,12 @@ fn change_window_height(handle: AppHandle, height: u32) {
#[derive(serde::Deserialize)]
struct ThemeChangedPayload {
#[allow(dead_code)]
is_dark_mode: bool,
}
#[derive(Clone, serde::Serialize)]
#[allow(dead_code)]
struct Payload {
args: Vec<String>,
cwd: String,
@@ -56,25 +60,23 @@ struct Payload {
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let mut ctx = tauri::generate_context!();
// Initialize logger
env_logger::init();
let ctx = tauri::generate_context!();
let mut app_builder = tauri::Builder::default();
// Set up logger first
app_builder = app_builder.plugin(set_up_tauri_logger());
#[cfg(desktop)]
{
app_builder = app_builder.plugin(tauri_plugin_single_instance::init(|_app, argv, _cwd| {
println!("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
.plugin(tauri_plugin_http::init())
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_autostart::init(
MacosLauncher::AppleScript,
MacosLauncher::LaunchAgent,
None,
))
.plugin(tauri_plugin_deep_link::init())
@@ -83,7 +85,14 @@ pub fn run() {
.plugin(tauri_plugin_fs_pro::init())
.plugin(tauri_plugin_macos_permissions::init())
.plugin(tauri_plugin_screenshots::init())
.plugin(tauri_plugin_process::init());
.plugin(tauri_plugin_process::init())
.plugin(
tauri_plugin_updater::Builder::new()
.default_version_comparator(crate::util::updater::custom_version_comparator)
.build(),
)
.plugin(tauri_plugin_windows_version::init())
.plugin(tauri_plugin_opener::init());
// Conditional compilation for macOS
#[cfg(target_os = "macos")]
@@ -101,7 +110,8 @@ pub fn run() {
show_coco,
hide_coco,
show_settings,
server::servers::get_server_token,
show_check,
hide_check,
server::servers::add_coco_server,
server::servers::remove_coco_server,
server::servers::list_coco_servers,
@@ -111,77 +121,91 @@ pub fn run() {
server::servers::disable_server,
server::auth::handle_sso_callback,
server::profile::get_user_profiles,
server::datasource::get_datasources_by_server,
server::datasource::datasource_search,
server::datasource::mcp_server_search,
server::connector::get_connectors_by_server,
search::query_coco_fusion,
assistant::chat_history,
assistant::new_chat,
assistant::send_message,
assistant::chat_create,
assistant::chat_chat,
assistant::session_chat_history,
assistant::open_session_chat,
assistant::close_session_chat,
assistant::cancel_session_chat,
assistant::delete_session_chat,
assistant::update_session_chat,
assistant::assistant_search,
assistant::assistant_get,
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
get_app_search_source,
server::attachment::upload_attachment,
server::attachment::get_attachment_by_ids,
server::attachment::delete_attachment,
server::transcription::transcription,
server::system_settings::get_system_settings,
extension::built_in::application::get_app_list,
extension::built_in::application::get_app_search_path,
extension::built_in::application::get_app_metadata,
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,
extension::set_extension_alias,
extension::register_extension_hotkey,
extension::unregister_extension_hotkey,
extension::is_extension_enabled,
extension::third_party::install::store::search_extension,
extension::third_party::install::store::extension_detail,
extension::third_party::install::store::install_extension_from_store,
extension::third_party::install::local_extension::install_local_extension,
extension::third_party::uninstall_extension,
settings::set_allow_self_signature,
settings::get_allow_self_signature,
assistant::ask_ai,
crate::common::document::open,
#[cfg(any(target_os = "macos", target_os = "windows"))]
extension::built_in::file_search::config::get_file_system_config,
#[cfg(any(target_os = "macos", target_os = "windows"))]
extension::built_in::file_search::config::set_file_system_config,
server::synthesize::synthesize,
util::file::get_file_icon,
setup::backend_setup,
util::app_lang::update_app_lang,
])
.setup(|app| {
let registry = SearchSourceRegistry::default();
app.manage(registry); // Store registry in Tauri's app state
app.manage(server::websocket::WebSocketManager::default());
// Get app handle
let app_handle = app.handle().clone();
// Create a single Tokio runtime instance
let rt = RT::new().expect("Failed to create Tokio runtime");
// Use the runtime to spawn the async initialization tasks
let init_app_handle = app.handle().clone();
rt.spawn(async move {
init(&init_app_handle).await; // Pass a reference to `app_handle`
});
shortcut::enable_shortcut(&app);
// enable_tray(app);
enable_autostart(app);
#[cfg(target_os = "macos")]
app.set_activation_policy(ActivationPolicy::Accessory);
// 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);
// println!("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()?;
}
log::trace!("hiding Dock icon on macOS");
app.set_activation_policy(tauri::ActivationPolicy::Accessory);
log::trace!("Dock icon should be hidden now");
}
// 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();
setup::default(app, main_window.clone(), settings_window.clone());
/* ----------- 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_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(())
})
.on_window_event(|window, event| match event {
WindowEvent::CloseRequested { api, .. } => {
dbg!("Close requested event received");
//dbg!("Close requested event received");
window.hide().unwrap();
api.prevent_close();
}
@@ -196,10 +220,10 @@ pub fn run() {
has_visible_windows,
..
} => {
dbg!(
"Reopen event received: has_visible_windows = {}",
has_visible_windows
);
// dbg!(
// "Reopen event received: has_visible_windows = {}",
// has_visible_windows
// );
if has_visible_windows {
return;
}
@@ -210,80 +234,62 @@ 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 {
eprintln!("Failed to load servers: {}", err);
log::error!("Failed to load servers: {}", err);
}
if let Err(err) = load_servers_token(app_handle).await {
eprintln!("Failed to load server tokens: {}", err);
log::error!("Failed to load server tokens: {}", err);
}
let coco_servers = server::servers::get_all_servers();
let coco_servers = server::servers::get_all_servers().await;
// Get the registry from Tauri's state
let registry: State<SearchSourceRegistry> = app_handle.state::<SearchSourceRegistry>();
// let registry: State<SearchSourceRegistry> = app_handle.state::<SearchSourceRegistry>();
for server in coco_servers {
let source = CocoSearchSource::new(server.clone(), Client::new());
registry.register_source(source).await;
crate::server::servers::try_register_server_to_search_source(app_handle.clone(), &server)
.await;
}
}
async fn init_app_search_source<R: Runtime>(app_handle: &AppHandle<R>) -> Result<(), String> {
let application_search =
local::application::ApplicationSearchSource::new(app_handle.clone(), 1000f64).await?;
// Register the application search source
let registry = app_handle.state::<SearchSourceRegistry>();
registry.register_source(application_search).await;
Ok(())
extension::built_in::pizza_engine_runtime::start_pizza_engine_runtime().await;
}
#[tauri::command]
async fn show_coco(app_handle: AppHandle) {
handle_open_coco(&app_handle);
if let Some(window) = app_handle.get_webview_window(MAIN_WINDOW_LABEL) {
move_window_to_active_monitor(&window);
let _ = window.show();
let _ = window.unminimize();
let _ = window.set_focus();
let _ = app_handle.emit("show-coco", ());
}
}
#[tauri::command]
fn hide_coco(app: tauri::AppHandle) {
if let Some(window) = app.get_window(MAIN_WINDOW_LABEL) {
match window.is_visible() {
Ok(true) => {
if let Err(err) = window.hide() {
eprintln!("Failed to hide the window: {}", err);
}
}
Ok(false) => {
println!("Window is already hidden.");
}
Err(err) => {
eprintln!("Failed to check window visibility: {}", err);
}
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);
} else {
log::debug!("Window successfully hidden.");
}
} else {
log::error!("Main window not found.");
}
}
fn handle_open_coco(app: &AppHandle) {
if let Some(window) = app.get_window(MAIN_WINDOW_LABEL) {
move_window_to_active_monitor(&window);
window.show().unwrap();
window.set_visible_on_all_workspaces(true).unwrap();
window.set_always_on_top(true).unwrap();
window.set_focus().unwrap();
}
}
fn move_window_to_active_monitor<R: Runtime>(window: &Window<R>) {
dbg!("Moving window to active monitor");
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() {
Ok(monitors) => monitors,
Err(e) => {
eprintln!("Failed to get monitors: {}", e);
log::error!("Failed to get monitors: {}", e);
return;
}
};
@@ -292,7 +298,7 @@ fn move_window_to_active_monitor<R: Runtime>(window: &Window<R>) {
let cursor_position = match window.cursor_position() {
Ok(pos) => Some(pos),
Err(e) => {
eprintln!("Failed to get cursor position: {}", e);
log::error!("Failed to get cursor position: {}", e);
None
}
};
@@ -321,7 +327,7 @@ fn move_window_to_active_monitor<R: Runtime>(window: &Window<R>) {
let monitor = match target_monitor.or_else(|| window.primary_monitor().ok().flatten()) {
Some(monitor) => monitor,
None => {
eprintln!("No monitor found!");
log::error!("No monitor found!");
return;
}
};
@@ -331,7 +337,7 @@ fn move_window_to_active_monitor<R: Runtime>(window: &Window<R>) {
if let Some(ref prev_name) = *previous_monitor_name {
if name.to_string() == *prev_name {
println!("Currently on the same monitor");
log::debug!("Currently on the same monitor");
return;
}
@@ -345,7 +351,7 @@ fn move_window_to_active_monitor<R: Runtime>(window: &Window<R>) {
let window_size = match window.inner_size() {
Ok(size) => size,
Err(e) => {
eprintln!("Failed to get window size: {}", e);
log::error!("Failed to get window size: {}", e);
return;
}
};
@@ -359,125 +365,19 @@ fn move_window_to_active_monitor<R: Runtime>(window: &Window<R>) {
// Move the window to the new position
if let Err(e) = window.set_position(PhysicalPosition::new(window_x, window_y)) {
eprintln!("Failed to move window: {}", e);
log::error!("Failed to move window: {}", e);
}
if let Some(name) = monitor.name() {
println!("Window moved to monitor: {}", name);
log::debug!("Window moved to monitor: {}", name);
let mut previous_monitor = PREVIOUS_MONITOR_NAME.lock().unwrap();
*previous_monitor = Some(name.to_string());
}
}
fn handle_hide_coco(app: &AppHandle) {
if let Some(window) = app.get_window(MAIN_WINDOW_LABEL) {
if let Err(err) = window.hide() {
eprintln!("Failed to hide the window: {}", err);
} else {
println!("Window successfully hidden.");
}
} else {
eprintln!("Main window not found.");
}
}
fn enable_tray(app: &mut tauri::App) {
use tauri::{
image::Image,
menu::{MenuBuilder, MenuItem},
tray::TrayIconBuilder,
};
let quit_i = MenuItem::with_id(app, "quit", "Quit Coco", true, None::<&str>).unwrap();
let settings_i = MenuItem::with_id(app, "settings", "Settings...", true, None::<&str>).unwrap();
let open_i = MenuItem::with_id(app, "open", "Show Coco", true, None::<&str>).unwrap();
// let about_i = MenuItem::with_id(app, "about", "About Coco", true, None::<&str>).unwrap();
// let hide_i = MenuItem::with_id(app, "hide", "Hide Coco", true, None::<&str>).unwrap();
let menu = MenuBuilder::new(app)
.item(&open_i)
.separator()
// .item(&hide_i)
// .item(&about_i)
.item(&settings_i)
.separator()
.item(&quit_i)
.build()
.unwrap();
let _tray = TrayIconBuilder::with_id("tray")
.icon_as_template(true)
// .icon(app.default_window_icon().unwrap().clone())
.icon(
Image::from_bytes(include_bytes!("../assets/tray-mac.ico"))
.expect("Failed to load icon"),
)
.menu(&menu)
.on_menu_event(|app, event| match event.id.as_ref() {
"open" => {
handle_open_coco(app);
}
"hide" => {
handle_hide_coco(app);
}
"about" => {
let _ = app.emit("open_settings", "about");
}
"settings" => {
// windows failed to open second window, issue: https://github.com/tauri-apps/tauri/issues/11144 https://github.com/tauri-apps/tauri/issues/8196
//#[cfg(windows)]
let _ = app.emit("open_settings", "settings");
// #[cfg(not(windows))]
// open_settings(&app);
}
"quit" => {
println!("quit menu item was clicked");
app.exit(0);
}
_ => {
println!("menu item {:?} not handled", event.id);
}
})
.build(app)
.unwrap();
}
#[allow(dead_code)]
fn open_settings(app: &tauri::AppHandle) {
use tauri::webview::WebviewBuilder;
println!("settings menu item was clicked");
let window = app.get_webview_window("settings");
if let Some(window) = window {
window.show().unwrap();
window.set_focus().unwrap();
} else {
let window = tauri::window::WindowBuilder::new(app, "settings")
.title("Settings Window")
.fullscreen(false)
.resizable(false)
.minimizable(false)
.maximizable(false)
.inner_size(800.0, 600.0)
.build()
.unwrap();
let webview_builder =
WebviewBuilder::new("settings", tauri::WebviewUrl::App("/ui/settings".into()));
let _webview = window
.add_child(
webview_builder,
tauri::LogicalPosition::new(0, 0),
window.inner_size().unwrap(),
)
.unwrap();
}
}
#[tauri::command]
async fn get_app_search_source<R: Runtime>(app_handle: AppHandle<R>) -> Result<(), String> {
init_app_search_source(&app_handle).await?;
async fn get_app_search_source(app_handle: AppHandle) -> Result<(), String> {
let _ = server::connector::refresh_all_connectors(&app_handle).await;
let _ = server::datasource::refresh_all_datasources(&app_handle).await;
@@ -486,5 +386,166 @@ async fn get_app_search_source<R: Runtime>(app_handle: AppHandle<R>) -> Result<(
#[tauri::command]
async fn show_settings(app_handle: AppHandle) {
open_settings(&app_handle);
log::debug!("settings menu item was clicked");
let window = app_handle
.get_webview_window(SETTINGS_WINDOW_LABEL)
.expect("we have a settings window");
window.show().unwrap();
window.unminimize().unwrap();
window.set_focus().unwrap();
}
#[tauri::command]
async fn show_check(app_handle: AppHandle) {
log::debug!("check menu item was clicked");
let window = app_handle
.get_webview_window(CHECK_WINDOW_LABEL)
.expect("we have a check window");
window.show().unwrap();
window.unminimize().unwrap();
window.set_focus().unwrap();
}
#[tauri::command]
async fn hide_check(app_handle: AppHandle) {
log::debug!("check window was closed");
let window = &app_handle
.get_webview_window(CHECK_WINDOW_LABEL)
.expect("we have a check window");
window.hide().unwrap();
}
/// Log format:
///
/// ```text
/// [time] [log level] [file module:line] message
/// ```
///
/// Example:
///
///
/// ```text
/// [05-11 17:00:00] [INF] [coco_lib:625] Coco-AI started
/// ```
fn set_up_tauri_logger() -> TauriPlugin<tauri::Wry> {
use log::Level;
use log::LevelFilter;
use tauri_plugin_log::Builder;
/// Coco-AI app's default log level.
const DEFAULT_LOG_LEVEL: LevelFilter = LevelFilter::Info;
const LOG_LEVEL_ENV_VAR: &str = "COCO_LOG";
fn format_log_level(level: Level) -> &'static str {
match level {
Level::Trace => "TRC",
Level::Debug => "DBG",
Level::Info => "INF",
Level::Warn => "WAR",
Level::Error => "ERR",
}
}
fn format_target_and_line(record: &log::Record) -> String {
let mut str = record.target().to_string();
if let Some(line) = record.line() {
str.push(':');
str.push_str(&line.to_string());
}
str
}
/// Allow us to configure dynamic log levels via environment variable `COCO_LOG`.
///
/// Generally, it mirros the behavior of `env_logger`. Syntax: `COCO_LOG=[target][=][level][,...]`
///
/// * If this environment variable is not set, use the default log level.
/// * If it is set, respect it:
///
/// * `COCO_LOG=coco_lib` turns on all logging for the `coco_lib` module, which is
/// equivalent to `COCO_LOG=coco_lib=trace`
/// * `COCO_LOG=trace` turns on all logging for the application, regardless of its name
/// * `COCO_LOG=TRACE` turns on all logging for the application, regardless of its name (same as previous)
/// * `COCO_LOG=reqwest=debug` turns on debug logging for `reqwest`
/// * `COCO_LOG=trace,tauri=off` turns on all the logging except for the logs come from `tauri`
/// * `COCO_LOG=off` turns off all logging for the application
/// * `COCO_LOG=` Since the value is empty, turns off all logging for the application as well
fn dynamic_log_level(mut builder: Builder) -> Builder {
let Some(log_levels) = std::env::var_os(LOG_LEVEL_ENV_VAR) else {
return builder.level(DEFAULT_LOG_LEVEL);
};
builder = builder.level(LevelFilter::Off);
let log_levels = log_levels.into_string().unwrap_or_else(|e| {
panic!(
"The value '{}' set in environment varaible '{}' is not UTF-8 encoded",
// Cannot use `.display()` here becuase that requires MSRV 1.87.0
e.to_string_lossy(),
LOG_LEVEL_ENV_VAR
)
});
// COCO_LOG=[target][=][level][,...]
let target_log_levels = log_levels.split(',');
for target_log_level in target_log_levels {
#[allow(clippy::collapsible_else_if)]
if let Some(char_index) = target_log_level.chars().position(|c| c == '=') {
let (target, equal_sign_and_level) = target_log_level.split_at(char_index);
// Remove the equal sign, we know it takes 1 byte
let level = &equal_sign_and_level[1..];
if let Ok(level) = level.parse::<LevelFilter>() {
// Here we have to call `.to_string()` because `Cow<'static, str>` requires `&'static str`
builder = builder.level_for(target.to_string(), level);
} else {
panic!(
"log level '{}' set in '{}={}' is invalid",
level, target, level
);
}
} else {
if let Ok(level) = target_log_level.parse::<LevelFilter>() {
// This is a level
builder = builder.level(level);
} else {
// This is a target, enable all the logging
//
// Here we have to call `.to_string()` because `Cow<'static, str>` requires `&'static str`
builder = builder.level_for(target_log_level.to_string(), LevelFilter::Trace);
}
}
}
builder
}
// When running the built binary, set `COCO_LOG` to `coco_lib=trace` to capture all logs
// that come from Coco in the log file, which helps with debugging.
if !tauri::is_dev() {
// We have absolutely no guarantee that we (We have control over the Rust
// code, but definitely no idea about the libc C code, all the shared objects
// that we will link) will not concurrently read/write `envp`, so just use unsafe.
unsafe {
std::env::set_var("COCO_LOG", "coco_lib=trace");
}
}
let mut builder = tauri_plugin_log::Builder::new();
builder = builder.format(|out, message, record| {
let now = chrono::Local::now().format("%m-%d %H:%M:%S");
let level = format_log_level(record.level());
let target_and_line = format_target_and_line(record);
out.finish(format_args!(
"[{}] [{}] [{}] {}",
now, level, target_and_line, message
));
});
builder = dynamic_log_level(builder);
builder.build()
}

View File

@@ -1,158 +0,0 @@
use crate::common::document::{DataSourceReference, Document};
use crate::common::search::{QueryResponse, QuerySource, SearchQuery};
use crate::common::traits::{SearchError, SearchSource};
use crate::local::LOCAL_QUERY_SOURCE_TYPE;
use applications::{AppInfo, AppInfoContext};
use async_trait::async_trait;
use base64::encode;
use fuzzy_prefix_search::Trie;
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use tauri::{AppHandle, Runtime};
use tauri_plugin_fs_pro::{icon, name};
pub struct ApplicationSearchSource {
base_score: f64,
icons: HashMap<String, PathBuf>,
application_paths: Trie<String>,
}
impl ApplicationSearchSource {
pub async fn new<R: Runtime>(
app_handle: AppHandle<R>,
base_score: f64,
) -> Result<Self, String> {
let application_paths = Trie::new();
let mut icons = HashMap::new();
let mut ctx = AppInfoContext::new(vec![]);
ctx.refresh_apps().map_err(|err| err.to_string())?; // must refresh apps before getting them
let apps = ctx.get_all_apps();
for app in &apps {
let path = if cfg!(target_os = "macos") {
app.app_desktop_path.clone()
} else {
app.app_path_exe
.clone()
.unwrap_or(PathBuf::from("Path not found"))
};
let search_word = name(path.clone()).await;
let icon = icon(app_handle.clone(), path.clone(), Some(256))
.await
.map_err(|err| err.to_string())?;
let path_string = path.to_string_lossy().into_owned();
if search_word.is_empty() || search_word.eq("coco-ai") {
continue;
}
application_paths.insert(&search_word, path_string.clone());
icons.insert(path_string, icon);
}
Ok(ApplicationSearchSource {
base_score,
icons,
application_paths,
})
}
}
#[async_trait]
impl SearchSource for ApplicationSearchSource {
fn get_type(&self) -> QuerySource {
QuerySource {
r#type: LOCAL_QUERY_SOURCE_TYPE.into(),
name: hostname::get()
.unwrap_or("My Computer".into())
.to_string_lossy()
.into(),
id: "local_applications".into(),
}
}
async fn search(&self, query: SearchQuery) -> Result<QueryResponse, SearchError> {
let query_string = query
.query_strings
.get("query")
.unwrap_or(&"".to_string())
.to_lowercase();
if query_string.is_empty() {
return Ok(QueryResponse {
source: self.get_type(),
hits: Vec::new(),
total_hits: 0,
});
}
let mut total_hits = 0;
let mut hits = Vec::new();
let mut results = self
.application_paths
.search_within_distance_scored(&query_string, 3);
// Check for NaN or extreme score values and handle them properly
results.sort_by(|a, b| {
// If either score is NaN, consider them equal (you can customize this logic as needed)
if a.score.is_nan() || b.score.is_nan() {
std::cmp::Ordering::Equal
} else {
// Otherwise, compare the scores as usual
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
}
});
if !results.is_empty() {
for result in results {
let file_name_str = result.word;
let file_path_str = result.data.get(0).unwrap().to_string();
let file_path = PathBuf::from(file_path_str.clone());
let cleaned_file_name = name(file_path).await;
total_hits += 1;
let mut doc = Document::new(
Some(DataSourceReference {
r#type: Some(LOCAL_QUERY_SOURCE_TYPE.into()),
name: Some("Applications".into()),
id: Some(file_name_str.clone()),
}),
file_path_str.clone(),
"Application".to_string(),
cleaned_file_name,
file_path_str.clone(),
);
// Attach icon if available
if let Some(icon_path) = self.icons.get(file_path_str.as_str()) {
// doc.icon = Some(format!("file://{}", icon_path.to_string_lossy()));
// dbg!(&doc.icon);
if let Ok(icon_data) = read_icon_and_encode(icon_path) {
doc.icon = Some(format!("data:image/png;base64,{}", icon_data));
}
}
hits.push((doc, self.base_score + result.score as f64));
}
}
Ok(QueryResponse {
source: self.get_type(),
hits,
total_hits,
})
}
}
// Function to read the icon file and convert it to base64
fn read_icon_and_encode(icon_path: &Path) -> Result<String, std::io::Error> {
// Read the icon file as binary data
let icon_data = fs::read(icon_path)?;
// Encode the data to base64
Ok(encode(&icon_data))
}

View File

@@ -1,4 +0,0 @@
pub mod application;
pub mod file_system;
pub const LOCAL_QUERY_SOURCE_TYPE: &str = "local";

View File

@@ -1,5 +1,112 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use std::fs::OpenOptions;
use std::io::Write;
use std::path::PathBuf;
/// Helper function to return the log directory.
///
/// This should return the same value as `tauri_app_handle.path().app_log_dir().unwrap()`.
fn app_log_dir() -> PathBuf {
// This function `app_log_dir()` is for the panic hook, which should be set
// before Tauri performs any initialization. At that point, we do not have
// access to the identifier provided by Tauri, so we need to define our own
// one here.
//
// NOTE: If you update identifier in the following files, update this one
// as well!
//
// src-tauri/tauri.linux.conf.json
// src-tauri/Entitlements.plist
// src-tauri/tauri.conf.json
// src-tauri/Info.plist
const IDENTIFIER: &str = "rs.coco.app";
#[cfg(target_os = "macos")]
let path = dirs::home_dir()
.expect("cannot find the home directory, Coco should never run in such a environment")
.join("Library/Logs")
.join(IDENTIFIER);
#[cfg(not(target_os = "macos"))]
let path = dirs::data_local_dir()
.expect("app local dir is None, we should not encounter this")
.join(IDENTIFIER)
.join("logs");
path
}
/// Set up panic hook to log panic information to a file
fn setup_panic_hook() {
std::panic::set_hook(Box::new(|panic_info| {
let timestamp = chrono::Local::now();
// "%Y-%m-%d %H:%M:%S"
//
// I would like to use the above format, but Windows does not allow that
// and complains with OS error 123.
let datetime_str = timestamp.format("%Y-%m-%d-%H-%M-%S").to_string();
let log_dir = app_log_dir();
// Ensure the log directory exists
if let Err(e) = std::fs::create_dir_all(&log_dir) {
eprintln!("Panic hook error: failed to create log directory: {}", e);
return;
}
let panic_file = log_dir.join(format!("{}_rust_panic.log", datetime_str));
// Prepare panic information
let panic_message = if let Some(s) = panic_info.payload().downcast_ref::<&str>() {
s.to_string()
} else if let Some(s) = panic_info.payload().downcast_ref::<String>() {
s.clone()
} else {
"Unknown panic message".to_string()
};
let location = if let Some(location) = panic_info.location() {
format!(
"{}:{}:{}",
location.file(),
location.line(),
location.column()
)
} else {
"Unknown location".to_string()
};
// Use `force_capture()` instead of `capture()` as we want backtrace
// regardless of whether the corresponding env vars are set or not.
let backtrace = std::backtrace::Backtrace::force_capture();
let panic_log = format!(
"Time: [{}]\nLocation: [{}]\nMessage: [{}]\nBacktrace: \n{}",
datetime_str, location, panic_message, backtrace
);
// Write to panic file
match OpenOptions::new()
.create(true)
.append(true)
.open(&panic_file)
{
Ok(mut file) => {
if let Err(e) = writeln!(file, "{}", panic_log) {
eprintln!("Panic hook error: Failed to write panic to file: {}", e);
}
}
Err(e) => {
eprintln!("Panic hook error: Failed to open panic log file: {}", e);
}
}
}));
}
fn main() {
// Panic hook setup should be the first thing to do, everything could panic!
setup_panic_hook();
coco_lib::run();
}

View File

@@ -1,119 +1,275 @@
use crate::common::error::SearchError;
use crate::common::register::SearchSourceRegistry;
use crate::common::search::{
FailedRequest, MultiSourceQueryResponse, QueryHits, QuerySource, SearchQuery,
};
use crate::common::traits::SearchError;
use futures::stream::FuturesUnordered;
use crate::common::traits::SearchSource;
use crate::server::servers::logout_coco_server;
use crate::server::servers::mark_server_as_offline;
use function_name::named;
use futures::StreamExt;
use futures::stream::FuturesUnordered;
use reqwest::StatusCode;
use std::cmp::Reverse;
use std::collections::HashMap;
use std::collections::HashSet;
use tauri::{AppHandle, Manager, Runtime};
use tokio::time::{timeout, Duration};
use std::sync::Arc;
use tauri::{AppHandle, Manager};
use tokio::time::{Duration, timeout};
#[named]
#[tauri::command]
pub async fn query_coco_fusion<R: Runtime>(
app_handle: AppHandle<R>,
pub async fn query_coco_fusion(
tauri_app_handle: AppHandle,
from: u64,
size: u64,
query_strings: HashMap<String, String>,
query_timeout: u64,
) -> Result<MultiSourceQueryResponse, SearchError> {
let search_sources = app_handle.state::<SearchSourceRegistry>();
let opt_query_source_id = query_strings.get("querysource");
let search_sources = tauri_app_handle.state::<SearchSourceRegistry>();
let query_source_list = search_sources.get_sources().await;
let 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: [{:?}]",
function_name!(),
from,
size,
query_strings,
timeout_duration
);
// Dispatch to different `query_coco_fusion_xxx()` functions.
if let Some(query_source_id) = opt_query_source_id {
query_coco_fusion_single_query_source(
tauri_app_handle,
query_source_list,
query_source_id.clone(),
timeout_duration,
search_query,
)
.await
} else {
query_coco_fusion_multi_query_sources(
tauri_app_handle,
query_source_list,
timeout_duration,
search_query,
)
.await
}
}
/// 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 sources_future = search_sources.get_sources();
let mut futures = FuturesUnordered::new();
let mut sources = HashMap::new();
let sources_list = sources_future.await;
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();
// Time limit for each query
let timeout_duration = Duration::from_millis(500); //TODO, settings
// Push all queries into futures
for query_source in sources_list {
let query_source_type = query_source.get_type().clone();
sources.insert(query_source_type.id.clone(), query_source_type);
let query = SearchQuery::new(from, size, query_strings.clone());
let query_source_clone = query_source.clone(); // Clone Arc to avoid ownership issues
futures.push(tokio::spawn(async move {
// Timeout each query execution
timeout(timeout_duration, async {
query_source_clone.search(query).await
})
.await
}));
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;
let mut need_rerank = true; //TODO set default to false when boost supported in Pizza
let mut failed_requests = Vec::new();
let mut all_hits: Vec<(String, QueryHits, f64)> = Vec::new();
let mut hits_per_source: HashMap<String, Vec<(QueryHits, f64)>> = HashMap::new();
while let Some(result) = futures.next().await {
match result {
Ok(Ok(Ok(response))) => {
total_hits += response.total_hits;
let source_id = response.source.id.clone();
if query_source_list_len > 1 {
need_rerank = true; // If we have more than one source, we need to rerank the hits
}
for (doc, score) in response.hits {
let query_hit = QueryHits {
source: Some(response.source.clone()),
score,
document: doc,
};
while let Some((query_source, timeout_result)) = futures.next().await {
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;
let source_id = response.source.id.clone();
all_hits.push((source_id.clone(), query_hit.clone(), score));
for (document, score) in response.hits {
log::debug!(
"document from query source [{}]: ID [{}], title [{:?}], score [{}]",
response.source.id,
document.id,
document.title,
score
);
hits_per_source
.entry(source_id.clone())
.or_insert_with(Vec::new)
.push((query_hit, score));
let query_hit = QueryHits {
source: Some(response.source.clone()),
score,
document,
};
all_hits.push((source_id.clone(), query_hit.clone(), score));
hits_per_source
.entry(source_id.clone())
.or_insert_with(Vec::new)
.push((query_hit, score));
}
}
}
Ok(Err(err)) => {
failed_requests.push(FailedRequest {
source: QuerySource {
r#type: "N/A".into(),
name: "N/A".into(),
id: "N/A".into(),
},
status: 0,
error: Some(err.to_string()),
reason: None,
});
}
// Timeout reached, skip this request
Ok(_) => {
failed_requests.push(FailedRequest {
source: QuerySource {
r#type: "N/A".into(),
name: "N/A".into(),
id: "N/A".into(),
},
status: 0,
error: Some("Query source timed out".to_string()),
reason: None,
});
}
Err(_) => {
failed_requests.push(FailedRequest {
source: QuerySource {
r#type: "N/A".into(),
name: "N/A".into(),
id: "N/A".into(),
},
status: 0,
error: Some("Task panicked".to_string()),
reason: None,
});
}
Err(search_error) => {
query_coco_fusion_handle_failed_request(
tauri_app_handle.clone(),
&mut failed_requests,
query_source,
search_error,
)
.await;
}
},
}
}
// Sort hits within each source by score (descending)
for hits in hits_per_source.values_mut() {
hits.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
hits.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Greater));
}
let total_sources = hits_per_source.len();
@@ -129,16 +285,71 @@ pub async fn query_coco_fusion<R: Runtime>(
// Distribute hits fairly across sources
for (_source_id, hits) in &mut hits_per_source {
let take_count = hits.len().min(max_hits_per_source);
for (doc, _) in hits.drain(0..take_count) {
for (doc, score) in hits.drain(0..take_count) {
if !seen_docs.contains(&doc.document.id) {
seen_docs.insert(doc.document.id.clone());
log::debug!(
"collect doc: {}, {:?}, {}",
doc.document.id,
doc.document.title,
score
);
final_hits.push(doc);
}
}
}
// If we still need more hits, take the highest-scoring remaining ones
if final_hits.len() < size as usize {
log::debug!("final hits: {:?}", final_hits.len());
let mut unique_sources = HashSet::new();
for hit in &final_hits {
if let Some(source) = &hit.source {
if source.id != crate::extension::built_in::calculator::DATA_SOURCE_ID {
unique_sources.insert(&source.id);
}
}
}
log::debug!(
"Multiple sources found: {:?}, no rerank needed",
unique_sources
);
if unique_sources.len() < 1 {
need_rerank = false; // If we have hits from multiple sources, we don't need to rerank
}
if need_rerank && final_hits.len() > 1 {
// Precollect (index, title)
let titles_to_score: Vec<(usize, &str)> = final_hits
.iter()
.enumerate()
.filter_map(|(idx, hit)| {
let source = hit.source.as_ref()?;
let title = hit.document.title.as_deref()?;
if source.id != crate::extension::built_in::calculator::DATA_SOURCE_ID {
Some((idx, title))
} else {
None
}
})
.collect();
// Score them
let scored_hits = boosted_levenshtein_rerank(query_keyword.as_str(), titles_to_score);
// Sort descending by score
let mut scored_hits = scored_hits;
scored_hits.sort_by_key(|&(_, score)| Reverse((score * 1000.0) as u64));
// Apply new scores to final_hits
for (idx, score) in scored_hits.into_iter().take(size as usize) {
final_hits[idx].score = score;
}
} else if final_hits.len() < size as usize {
// If we still need more hits, take the highest-scoring remaining ones
let remaining_needed = size as usize - final_hits.len();
// Sort all hits by score descending, removing duplicates by document ID
@@ -168,9 +379,96 @@ pub async fn query_coco_fusion<R: Runtime>(
.unwrap_or(std::cmp::Ordering::Equal)
});
if final_hits.len() < 5 {
//TODO: Add a recommendation system to suggest more sources
log::info!(
"Less than 5 hits found, consider using recommendation to find more suggestions."
);
//local: recent history, local extensions
//remote: ai agents, quick links, other tasks, managed by server
}
Ok(MultiSourceQueryResponse {
failed: failed_requests,
hits: final_hits,
total_hits,
})
}
fn boosted_levenshtein_rerank(query: &str, titles: Vec<(usize, &str)>) -> Vec<(usize, f64)> {
use strsim::levenshtein;
let query_lower = query.to_lowercase();
titles
.into_iter()
.map(|(idx, title)| {
let mut score = 0.0;
if title.contains(query) {
score += 0.4;
} else if title.to_lowercase().contains(&query_lower) {
score += 0.2;
}
let dist = levenshtein(&query_lower, &title.to_lowercase());
let max_len = query_lower.len().max(title.len());
if max_len > 0 {
score += (1.0 - (dist as f64 / max_len as f64)) as f32;
}
(idx, score.min(1.0) as f64)
})
.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

@@ -0,0 +1,113 @@
use super::servers::{get_server_by_id, get_server_token};
use crate::common::http::get_response_body_text;
use crate::server::http_client::HttpClient;
use reqwest::multipart::{Form, Part};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::{collections::HashMap, path::PathBuf};
use tauri::command;
use tokio::fs::File;
use tokio_util::codec::{BytesCodec, FramedRead};
#[derive(Debug, Serialize, Deserialize)]
pub struct UploadAttachmentResponse {
pub acknowledged: bool,
pub attachments: Vec<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct DeleteAttachmentResponse {
pub _id: String,
pub result: String,
}
#[command]
pub async fn upload_attachment(
server_id: String,
file_paths: Vec<PathBuf>,
) -> Result<UploadAttachmentResponse, String> {
let mut form = Form::new();
for file_path in file_paths {
let file = File::open(&file_path)
.await
.map_err(|err| err.to_string())?;
let stream = FramedRead::new(file, BytesCodec::new());
let file_name = file_path
.file_name()
.and_then(|n| n.to_str())
.ok_or("Invalid filename")?;
let part =
Part::stream(reqwest::Body::wrap_stream(stream)).file_name(file_name.to_string());
form = form.part("files", part);
}
let server = get_server_by_id(&server_id)
.await
.ok_or("Server not found")?;
let url = HttpClient::join_url(&server.endpoint, &format!("attachment/_upload"));
let token = get_server_token(&server_id).await;
let mut headers = HashMap::new();
if let Some(token) = token {
headers.insert("X-API-TOKEN".to_string(), token.access_token);
}
let client = reqwest::Client::new();
let response = client
.post(url)
.multipart(form)
.headers((&headers).try_into().map_err(|err| format!("{}", err))?)
.send()
.await
.map_err(|err| err.to_string())?;
let body = get_response_body_text(response).await?;
serde_json::from_str::<UploadAttachmentResponse>(&body)
.map_err(|e| format!("Failed to parse upload response: {}", e))
}
#[command]
pub async fn get_attachment_by_ids(
server_id: String,
attachments: Vec<String>,
) -> Result<Value, String> {
println!("get_attachment_by_ids server_id: {}", server_id);
println!("get_attachment_by_ids attachments: {:?}", attachments);
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))?;
let body = get_response_body_text(response).await?;
serde_json::from_str::<Value>(&body)
.map_err(|e| format!("Failed to parse attachment response: {}", e))
}
#[command]
pub async fn delete_attachment(server_id: String, id: String) -> Result<bool, String> {
let response = HttpClient::delete(&server_id, &format!("/attachment/{}", id), None, None)
.await
.map_err(|e| format!("Request error: {}", e))?;
let body = get_response_body_text(response).await?;
let parsed: DeleteAttachmentResponse = serde_json::from_str(&body)
.map_err(|e| format!("Failed to parse delete response: {}", e))?;
parsed
.result
.eq("deleted")
.then_some(true)
.ok_or_else(|| "Delete operation was not successful".to_string())
}

View File

@@ -1,94 +1,51 @@
use crate::common::auth::RequestAccessTokenResponse;
use crate::common::register::SearchSourceRegistry;
use crate::common::server::ServerAccessToken;
use crate::server::http_client::HttpClient;
use crate::server::profile::get_user_profiles;
use crate::server::search::CocoSearchSource;
use crate::server::servers::{get_server_by_id, persist_servers, persist_servers_token, save_access_token, save_server};
use reqwest::{Client, StatusCode};
use std::collections::HashMap;
use tauri::{AppHandle, Manager, Runtime};
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;
#[allow(dead_code)]
fn request_access_token_url(request_id: &str) -> String {
// Remove the endpoint part and keep just the path for the request
format!("/auth/request_access_token?request_id={}", request_id)
}
#[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,
) -> Result<(), String> {
// Retrieve the server details using the server ID
let server = get_server_by_id(&server_id);
let server = get_server_by_id(&server_id).await;
let expire_in = 3600; // TODO, need to update to actual expire_in value
if let Some(mut server) = server {
// Prepare the URL for requesting the access token (endpoint is base URL, path is relative)
// save_access_token(server_id.clone(), ServerAccessToken::new(server_id.clone(), code.clone(), 60 * 15));
let path = request_access_token_url(&request_id);
// Save the access token for the server
let access_token = ServerAccessToken::new(server_id.clone(), code.clone(), expire_in);
// dbg!(&server_id, &request_id, &code, &token);
save_access_token(server_id.clone(), access_token).await;
persist_servers_token(&app_handle).await?;
// Send the request for the access token using the util::http::HttpClient::get method
let mut header = HashMap::new();
header.insert("Authorization".to_string(), format!("Bearer {}", code).to_string());
let response = HttpClient::advanced_post(&server_id, &path, Some(header), None, None)
.await
.map_err(|e| format!("Failed to send request to the server: {}", e))?;
// Register the server to the search source
try_register_server_to_search_source(app_handle.clone(), &server).await;
if response.status() == StatusCode::OK {
// Check if the response has a valid content length
if let Some(content_length) = response.content_length() {
if content_length > 0 {
// Deserialize the response body to get the access token
let token_result: Result<RequestAccessTokenResponse, _> = response.json().await;
// Update the server's profile using the util::http::HttpClient::get method
let profile = get_user_profiles(app_handle.clone(), server_id.clone()).await;
dbg!(&profile);
match token_result {
Ok(token) => {
// Save the access token for the server
let access_token = ServerAccessToken::new(
server_id.clone(),
token.access_token.clone(),
token.expire_in,
);
// dbg!(&server_id, &request_id, &code, &token);
save_access_token(server_id.clone(), access_token);
persist_servers_token(&app_handle)?;
let registry = app_handle.state::<SearchSourceRegistry>();
let source = CocoSearchSource::new(server.clone(), Client::new());
registry.register_source(source).await;
// Update the server's profile using the util::http::HttpClient::get method
let profile = get_user_profiles(app_handle.clone(), server_id.clone()).await;
dbg!(&profile);
match profile {
Ok(p) => {
server.profile = Some(p);
server.available = true;
save_server(&server);
persist_servers(&app_handle).await?;
Ok(())
}
Err(e) => Err(format!("Failed to get user profile: {}", e)),
}
}
Err(e) => Err(format!("Failed to deserialize the token response: {}", e)),
}
} else {
Err("Received empty response body.".to_string())
}
} else {
Err("Could not determine the content length.".to_string())
match profile {
Ok(p) => {
server.profile = Some(p);
server.available = true;
save_server(&server).await;
persist_servers(&app_handle).await?;
Ok(())
}
} else {
Err(format!(
"Request failed with status: {}, URL: {}, Code: {}, Response: {:?}",
response.status(),
path,
code,
response
))
Err(e) => Err(format!("Failed to get user profile: {}", e)),
}
} else {
Err(format!(
@@ -96,4 +53,4 @@ pub async fn handle_sso_callback<R: Runtime>(
server_id, request_id, code
))
}
}
}

View File

@@ -1,11 +1,12 @@
use crate::common::connector::Connector;
use crate::common::search::parse_search_results;
use crate::server::http_client::HttpClient;
use crate::server::http_client::{HttpClient, status_code_check};
use crate::server::servers::get_all_servers;
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>>>> =
@@ -28,12 +29,16 @@ 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> {
let servers = get_all_servers();
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
let mut server_map = HashMap::new();
for server in servers {
if !server.enabled {
continue;
}
// dbg!("start fetch connectors for server: {}", &server.id);
let connectors = match get_connectors_by_server(app_handle.clone(), server.id.clone()).await
{
@@ -65,6 +70,7 @@ pub async fn refresh_all_connectors<R: Runtime>(app_handle: &AppHandle<R>) -> Re
Ok(())
}
#[allow(dead_code)]
pub async fn get_connectors_from_cache_or_remote(
server_id: &str,
) -> Result<Vec<Connector>, String> {
@@ -96,17 +102,18 @@ pub async fn get_connectors_from_cache_or_remote(
pub async fn fetch_connectors_by_server(id: &str) -> Result<Vec<Connector>, String> {
// Use the generic GET method from HttpClient
let resp = HttpClient::get(&id, "/connector/_search",None)
let resp = HttpClient::get(&id, "/connector/_search", None)
.await
.map_err(|e| {
// dbg!("Error fetching connector for id {}: {}", &id, &e);
format!("Error fetching connector: {}", e)
})?;
status_code_check(&resp, &[StatusCode::OK, StatusCode::CREATED])?;
// Parse the search results directly from the response body
let datasource: Vec<Connector> = parse_search_results(resp).await.map_err(|e| {
e.to_string()
})?;
let datasource: Vec<Connector> = parse_search_results(resp)
.await
.map_err(|e| e.to_string())?;
// Save the connectors to the cache
save_connectors_to_cache(&id, datasource.clone());
@@ -115,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

@@ -1,12 +1,13 @@
use crate::common::datasource::DataSource;
use crate::common::search::parse_search_results;
use crate::server::connector::get_connector_by_id;
use crate::server::http_client::HttpClient;
use crate::server::http_client::{HttpClient, status_code_check};
use crate::server::servers::get_all_servers;
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>>>> =
@@ -22,6 +23,7 @@ pub fn save_datasource_to_cache(server_id: &str, datasources: Vec<DataSource>) {
cache.insert(server_id.to_string(), datasources_map);
}
#[allow(dead_code)]
pub fn get_datasources_from_cache(server_id: &str) -> Option<HashMap<String, DataSource>> {
let cache = DATASOURCE_CACHE.read().unwrap(); // Acquire read lock
// dbg!("cache: {:?}", &cache);
@@ -29,33 +31,36 @@ 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();
let servers = get_all_servers().await;
let mut server_map = HashMap::new();
for server in servers {
// dbg!("fetch datasources for server: {}", &server.id);
if !server.enabled {
continue;
}
// Attempt to get datasources by server, and continue even if it fails
let connectors =
match get_datasources_by_server(server.id.as_str()).await {
Ok(connectors) => {
// Process connectors only after fetching them
let connectors_map: HashMap<String, DataSource> = connectors
.into_iter()
.map(|connector| (connector.id.clone(), connector))
.collect();
// dbg!("connectors_map: {:?}", &connectors_map);
connectors_map
}
Err(_e) => {
// dbg!("Failed to get dataSources for server {}: {}", &server.id, e);
HashMap::new()
}
};
let connectors = match datasource_search(server.id.as_str(), None).await {
Ok(connectors) => {
// Process connectors only after fetching them
let connectors_map: HashMap<String, DataSource> = connectors
.into_iter()
.map(|connector| (connector.id.clone(), connector))
.collect();
// dbg!("connectors_map: {:?}", &connectors_map);
connectors_map
}
Err(_e) => {
// dbg!("Failed to get dataSources for server {}: {}", &server.id, e);
HashMap::new()
}
};
let mut new_map = HashMap::new();
for (id, datasource) in connectors.iter() {
@@ -79,27 +84,23 @@ pub async fn refresh_all_datasources<R: Runtime>(app_handle: &AppHandle<R>) -> R
cache.extend(server_map);
cache.len()
};
// dbg!("datasource_map size: {:?}", cache_size);
Ok(())
}
#[tauri::command]
pub async fn get_datasources_by_server(
pub async fn datasource_search(
id: &str,
query_params: Option<Vec<String>>, //["query=abc", "filter=er", "filter=efg", "from=0", "size=5"],
) -> Result<Vec<DataSource>, String> {
// Perform the async HTTP request outside the cache lock
let resp = HttpClient::get(id, "/datasource/_search", None)
let resp = HttpClient::post(id, "/datasource/_search", query_params, None)
.await
.map_err(|e| {
// dbg!("Error fetching datasource: {}", &e);
format!("Error fetching datasource: {}", e)
})?;
.map_err(|e| format!("Error fetching datasource: {}", e))?;
status_code_check(&resp, &[StatusCode::OK, StatusCode::CREATED])?;
// Parse the search results from the response
let datasources: Vec<DataSource> = parse_search_results(resp).await.map_err(|e| {
dbg!("Error parsing search results: {}", &e);
//dbg!("Error parsing search results: {}", &e);
e.to_string()
})?;
@@ -108,3 +109,26 @@ pub async fn get_datasources_by_server(
Ok(datasources)
}
#[tauri::command]
pub async fn mcp_server_search(
id: &str,
query_params: Option<Vec<String>>,
) -> Result<Vec<DataSource>, String> {
// Perform the async HTTP request outside the cache lock
let resp = HttpClient::post(id, "/mcp_server/_search", query_params, None)
.await
.map_err(|e| format!("Error fetching datasource: {}", e))?;
status_code_check(&resp, &[StatusCode::OK, StatusCode::CREATED])?;
// Parse the search results from the response
let mcp_server: Vec<DataSource> = parse_search_results(resp).await.map_err(|e| {
//dbg!("Error parsing search results: {}", &e);
e.to_string()
})?;
// Save the updated mcp_server to cache
// save_datasource_to_cache(&id, mcp_server.clone());
Ok(mcp_server)
}

View File

@@ -1,22 +1,52 @@
use crate::server::servers::{get_server_by_id, get_server_token};
use http::HeaderName;
use crate::util::app_lang::get_app_lang;
use crate::util::platform::Platform;
use http::{HeaderName, HeaderValue, StatusCode};
use once_cell::sync::Lazy;
use reqwest::{Client, Method, RequestBuilder};
use serde_json::Value;
use std::collections::HashMap;
use std::sync::LazyLock;
use std::time::Duration;
use tauri::ipc::RuntimeCapability;
use tokio::sync::Mutex;
pub static HTTP_CLIENT: Lazy<Mutex<Client>> = Lazy::new(|| {
let client = Client::builder()
.read_timeout(Duration::from_secs(3)) // Set a timeout of 3 second
.connect_timeout(Duration::from_secs(3)) // Set a timeout of 3 second
.timeout(Duration::from_secs(10)) // Set a timeout of 10 seconds
.danger_accept_invalid_certs(true) // example for self-signed certificates
pub(crate) fn new_reqwest_http_client(accept_invalid_certs: bool) -> Client {
Client::builder()
.read_timeout(Duration::from_secs(60)) // Set a timeout of 60 second
.connect_timeout(Duration::from_secs(30)) // Set a timeout of 30 second
.timeout(Duration::from_secs(5 * 60)) // Set a timeout of 5 minute
.danger_accept_invalid_certs(accept_invalid_certs) // allow self-signed certificates
.build()
.expect("Failed to build client");
Mutex::new(client)
.expect("Failed to build client")
}
pub static HTTP_CLIENT: Lazy<Mutex<Client>> = Lazy::new(|| {
let allow_self_signature = crate::settings::_get_allow_self_signature(
crate::GLOBAL_TAURI_APP_HANDLE
.get()
.expect("global tauri app store not set")
.clone(),
);
Mutex::new(new_reqwest_http_client(allow_self_signature))
});
/// These header values won't change during a process's lifetime.
static STATIC_HEADERS: LazyLock<HashMap<String, String>> = LazyLock::new(|| {
HashMap::from([
(
"X-OS-NAME".into(),
Platform::current()
.to_os_name_http_header_str()
.into_owned(),
),
(
"X-OS-VER".into(),
sysinfo::System::os_version()
.expect("sysinfo::System::os_version() should be Some on major systems"),
),
("X-OS-ARCH".into(), sysinfo::System::cpu_arch()),
("X-APP-NAME".into(), "coco-app".into()),
("X-APP-VER".into(), env!("CARGO_PKG_VERSION").into()),
])
});
pub struct HttpClient;
@@ -32,14 +62,33 @@ impl HttpClient {
pub async fn send_raw_request(
method: Method,
url: &str,
query_params: Option<HashMap<String, Value>>,
query_params: Option<Vec<String>>,
headers: Option<HashMap<String, String>>,
body: Option<reqwest::Body>,
) -> Result<reqwest::Response, String> {
let mut request_builder = Self::get_request_builder(method, url, headers, query_params, body).await;
log::debug!(
"Sending Request: {}, query_params: {:?}, header: {:?}, body: {:?}",
&url,
&query_params,
&headers,
&body
);
let request_builder =
Self::get_request_builder(method, url, headers, query_params, body).await;
let response = request_builder.send().await.map_err(|e| {
//dbg!("Failed to send request: {}", &e);
format!("Failed to send request: {}", e)
})?;
log::debug!(
"Request: {}, Response status: {:?}, header: {:?}",
&url,
&response.status(),
&response.headers()
);
let response = request_builder.send().await
.map_err(|e| format!("Failed to send request: {}", e))?;
Ok(response)
}
@@ -47,7 +96,7 @@ impl HttpClient {
method: Method,
url: &str,
headers: Option<HashMap<String, String>>,
query_params: Option<HashMap<String, Value>>, // Add query parameters
query_params: Option<Vec<String>>, // Add query parameters
body: Option<reqwest::Body>,
) -> RequestBuilder {
let client = HTTP_CLIENT.lock().await; // Acquire the lock on HTTP_CLIENT
@@ -55,21 +104,60 @@ impl HttpClient {
// Build the request
let mut request_builder = client.request(method.clone(), url);
if let Some(h) = headers {
let mut req_headers = reqwest::header::HeaderMap::new();
for (key, value) in h.into_iter() {
let _ = req_headers.insert(
HeaderName::from_bytes(key.as_bytes()).unwrap(),
reqwest::header::HeaderValue::from_str(&value).unwrap(),
// Populate the headers defined by us
let mut req_headers = reqwest::header::HeaderMap::new();
for (key, value) in STATIC_HEADERS.iter() {
let key = HeaderName::from_bytes(key.as_bytes())
.expect("headers defined by us should be valid");
let value = HeaderValue::from_str(value.trim()).unwrap_or_else(|e| {
panic!(
"header value [{}] is invalid, error [{}], this should be unreachable",
value, e
);
});
req_headers.insert(key, value);
}
let app_lang = get_app_lang().await.to_string();
req_headers.insert(
"X-APP-LANG",
HeaderValue::from_str(&app_lang).unwrap_or_else(|e| {
panic!(
"header value [{}] is invalid, error [{}], this should be unreachable",
app_lang, e
);
}),
);
// Headers from the function parameter
if let Some(h) = headers {
for (key, value) in h.into_iter() {
match (
HeaderName::from_bytes(key.as_bytes()),
HeaderValue::from_str(value.trim()),
) {
(Ok(name), Ok(val)) => {
req_headers.insert(name, val);
}
(Err(e), _) => {
eprintln!("Invalid header name: {:?}, error: {}", key, e);
}
(_, Err(e)) => {
eprintln!(
"Invalid header value for {}: {:?}, error: {}",
key, value, e
);
}
}
}
request_builder = request_builder.headers(req_headers);
}
if let Some(query) = query_params {
if let Some(params) = query_params {
let query: Vec<(&str, &str)> =
params.iter().filter_map(|s| s.split_once('=')).collect();
request_builder = request_builder.query(&query);
}
// Add body if present
if let Some(b) = body {
request_builder = request_builder.body(b);
@@ -83,45 +171,49 @@ impl HttpClient {
method: Method,
path: &str,
custom_headers: Option<HashMap<String, String>>,
query_params: Option<HashMap<String, Value>>,
query_params: Option<Vec<String>>,
body: Option<reqwest::Body>,
) -> Result<reqwest::Response, String> {
// Fetch the server using the server_id
let server = get_server_by_id(server_id);
let server = get_server_by_id(server_id).await;
if let Some(s) = server {
// Construct the URL
let url = HttpClient::join_url(&s.endpoint, path);
// Retrieve the token for the server (token is optional)
let token = get_server_token(server_id).map(|t| t.access_token.clone());
let token = get_server_token(server_id)
.await
.map(|t| t.access_token.clone());
let mut headers = if let Some(custom_headers) = custom_headers {
custom_headers
} else {
let mut headers = HashMap::new();
let headers = HashMap::new();
headers
};
if let Some(t) = token {
headers.insert(
"X-API-TOKEN".to_string(),
t,
);
headers.insert("X-API-TOKEN".to_string(), t);
}
// dbg!(&server_id);
// dbg!(&url);
// dbg!(&headers);
// log::debug!(
// "Sending request to server: {}, url: {}, headers: {:?}",
// &server_id,
// &url,
// &headers
// );
Self::send_raw_request(method, &url, query_params, Some(headers), body).await
} else {
Err("Server not found".to_string())
Err(format!("Server [{}] not found", server_id))
}
}
// Convenience method for GET requests (as it's the most common)
pub async fn get(server_id: &str, path: &str, query_params: Option<HashMap<String, Value>>, // Add query parameters
pub async fn get(
server_id: &str,
path: &str,
query_params: Option<Vec<String>>,
) -> Result<reqwest::Response, String> {
HttpClient::send_request(server_id, Method::GET, path, None, query_params, None).await
}
@@ -130,7 +222,7 @@ impl HttpClient {
pub async fn post(
server_id: &str,
path: &str,
query_params: Option<HashMap<String, Value>>, // Add query parameters
query_params: Option<Vec<String>>,
body: Option<reqwest::Body>,
) -> Result<reqwest::Response, String> {
HttpClient::send_request(server_id, Method::POST, path, None, query_params, body).await
@@ -140,27 +232,83 @@ impl HttpClient {
server_id: &str,
path: &str,
custom_headers: Option<HashMap<String, String>>,
query_params: Option<HashMap<String, Value>>, // Add query parameters
query_params: Option<Vec<String>>,
body: Option<reqwest::Body>,
) -> Result<reqwest::Response, String> {
HttpClient::send_request(server_id, Method::POST, path, custom_headers, query_params, body).await
HttpClient::send_request(
server_id,
Method::POST,
path,
custom_headers,
query_params,
body,
)
.await
}
// Convenience method for PUT requests
#[allow(dead_code)]
pub async fn put(
server_id: &str,
path: &str,
custom_headers: Option<HashMap<String, String>>,
query_params: Option<HashMap<String, Value>>, // Add query parameters
query_params: Option<Vec<String>>,
body: Option<reqwest::Body>,
) -> Result<reqwest::Response, String> {
HttpClient::send_request(server_id, Method::PUT, path, custom_headers, query_params, body).await
HttpClient::send_request(
server_id,
Method::PUT,
path,
custom_headers,
query_params,
body,
)
.await
}
// Convenience method for DELETE requests
pub async fn delete(server_id: &str, path: &str, custom_headers: Option<HashMap<String, String>>,
query_params: Option<HashMap<String, Value>>, // Add query parameters
#[allow(dead_code)]
pub async fn delete(
server_id: &str,
path: &str,
custom_headers: Option<HashMap<String, String>>,
query_params: Option<Vec<String>>,
) -> Result<reqwest::Response, String> {
HttpClient::send_request(server_id, Method::DELETE, path, custom_headers, query_params, None).await
HttpClient::send_request(
server_id,
Method::DELETE,
path,
custom_headers,
query_params,
None,
)
.await
}
}
/// Helper function to check status code.
///
/// If the status code is not in the `allowed_status_codes` list, return an error.
pub(crate) fn status_code_check(
response: &reqwest::Response,
allowed_status_codes: &[StatusCode],
) -> Result<(), String> {
let status_code = response.status();
if !allowed_status_codes.contains(&status_code) {
let msg = format!(
"Response of request [{}] status code failed: status code [{}], which is not in the 'allow' list {:?}",
response.url(),
status_code,
allowed_status_codes
.iter()
.map(|status| status.to_string())
.collect::<Vec<String>>()
);
log::warn!("{}", msg);
Err(msg)
} else {
Ok(())
}
}

View File

@@ -1,10 +1,13 @@
//! This file contains Rust APIs related to Coco Server management.
pub mod attachment;
pub mod auth;
pub mod servers;
pub mod connector;
pub mod datasource;
pub mod http_client;
pub mod profile;
pub mod search;
pub mod websocket;
pub mod servers;
pub mod synthesize;
pub mod system_settings;
pub mod transcription;

View File

@@ -1,10 +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
@@ -12,14 +13,16 @@ pub async fn get_user_profiles<R: Runtime>(
.await
.map_err(|e| format!("Error fetching profile: {}", e))?;
if let Some(content_length) = response.content_length() {
if content_length > 0 {
let profile: UserProfile = response
.json()
.await
.map_err(|e| format!("Failed to parse response: {}", e))?;
return Ok(profile);
}
// Use get_response_body_text to extract the body content
let response_body = get_response_body_text(response)
.await
.map_err(|e| format!("Failed to read response body: {}", e))?;
// Check if the response body is not empty before deserializing
if !response_body.is_empty() {
let profile: UserProfile = serde_json::from_str(&response_body)
.map_err(|e| format!("Failed to parse response: {}", e))?;
return Ok(profile);
}
Err("Profile not found or empty response".to_string())

View File

@@ -1,17 +1,17 @@
use crate::common::document::Document;
use crate::common::search::{
parse_search_response, QueryHits, QueryResponse, QuerySource, SearchQuery,
};
use crate::common::document::{Document, OnOpened};
use crate::common::error::SearchError;
use crate::common::http::get_response_body_text;
use crate::common::search::{QueryHits, QueryResponse, QuerySource, SearchQuery, SearchResponse};
use crate::common::server::Server;
use crate::common::traits::{SearchError, SearchSource};
use crate::common::traits::SearchSource;
use crate::server::http_client::HttpClient;
use crate::server::servers::get_server_token;
use async_trait::async_trait;
// use futures::stream::StreamExt;
use ordered_float::OrderedFloat;
use reqwest::{Client, Method, RequestBuilder};
use reqwest::StatusCode;
use std::collections::HashMap;
// use std::hash::Hash;
use tauri::AppHandle;
#[allow(dead_code)]
pub(crate) struct DocumentsSizedCollector {
size: u64,
/// Documents and scores
@@ -20,6 +20,7 @@ pub(crate) struct DocumentsSizedCollector {
docs: Vec<(String, Document, OrderedFloat<f64>)>,
}
#[allow(dead_code)]
impl DocumentsSizedCollector {
pub(crate) fn new(size: u64) -> Self {
// there will be size + 1 documents in docs at max
@@ -71,36 +72,11 @@ const COCO_SERVERS: &str = "coco-servers";
pub struct CocoSearchSource {
server: Server,
client: Client,
}
impl CocoSearchSource {
pub fn new(server: Server, client: Client) -> Self {
CocoSearchSource { server, client }
}
fn build_request_from_query(&self, query: &SearchQuery) -> RequestBuilder {
self.build_request(query.from, query.size, &query.query_strings)
}
fn build_request(
&self,
from: u64,
size: u64,
query_strings: &HashMap<String, String>,
) -> RequestBuilder {
let url = HttpClient::join_url(&self.server.endpoint, "/query/_search");
let mut request_builder = self.client.request(Method::GET, url);
if !self.server.public {
if let Some(token) = get_server_token(&self.server.id).map(|t| t.access_token) {
request_builder = request_builder.header("X-API-TOKEN", token);
}
}
request_builder
.query(&[("from", &from.to_string()), ("size", &size.to_string())])
.query(query_strings)
pub fn new(server: Server) -> Self {
CocoSearchSource { server }
}
}
@@ -114,58 +90,80 @@ impl SearchSource for CocoSearchSource {
}
}
// Directly return Result<QueryResponse, SearchError> instead of Future
async fn search(&self, query: SearchQuery) -> Result<QueryResponse, SearchError> {
let _server_id = self.server.id.clone();
let _server_name = self.server.name.clone();
let request_builder = self.build_request_from_query(&query);
async fn search(
&self,
_tauri_app_handle: AppHandle,
query: SearchQuery,
) -> Result<QueryResponse, SearchError> {
let url = "/query/_search";
let mut total_hits = 0;
let mut hits: Vec<(Document, f64)> = Vec::new();
// Send the HTTP request asynchronously
let response = request_builder.send().await;
let mut query_params = Vec::new();
match response {
Ok(response) => {
let status_code = response.status().as_u16();
// Add from/size as number values
query_params.push(format!("from={}", query.from));
query_params.push(format!("size={}", query.size));
if status_code >= 200 && status_code < 400 {
// Parse the response only if the status code is successful
match parse_search_response(response).await {
Ok(response) => {
let total_hits = response.hits.total.value as usize;
let hits: Vec<(Document, f64)> = response
.hits
.hits
.into_iter()
.map(|hit| {
// Handling Option<f64> in hit._score by defaulting to 0.0 if None
(hit._source, hit._score.unwrap_or(0.0)) // Use 0.0 if _score is None
})
.collect();
// Add query strings
for (key, value) in query.query_strings {
query_params.push(format!("{}={}", key, value));
}
// Return the QueryResponse with hits and total hits
Ok(QueryResponse {
source: self.get_type(),
hits,
total_hits,
})
}
Err(err) => {
// Parse error when response parsing fails
Err(SearchError::ParseError(err.to_string()))
}
}
} else {
// Handle unsuccessful HTTP status codes (e.g., 4xx, 5xx)
Err(SearchError::HttpError(format!(
"Request failed with status code: {}",
status_code
)))
let response = HttpClient::get(&self.server.id, &url, Some(query_params))
.await
.map_err(|e| SearchError::HttpError {
status_code: None,
msg: format!("{}", e),
})?;
let status_code = response.status();
if ![StatusCode::OK, StatusCode::CREATED].contains(&status_code) {
return Err(SearchError::HttpError {
status_code: Some(status_code),
msg: format!("Request failed with status code [{}]", status_code),
});
}
// Use the helper function to parse the response body
let response_body = get_response_body_text(response)
.await
.map_err(|e| SearchError::ParseError(e))?;
// Check if the response body is empty
if !response_body.is_empty() {
// log::info!("Search response body: {}", &response_body);
// Parse the search response from the body text
let parsed: SearchResponse<Document> = serde_json::from_str(&response_body)
.map_err(|e| SearchError::ParseError(format!("{}", e)))?;
// Process the parsed response
total_hits = parsed.hits.total.value as usize;
if let Some(items) = parsed.hits.hits {
for hit in items {
let mut document = hit._source;
// Default _score to 0.0 if None
let score = hit._score.unwrap_or(0.0);
let on_opened = document
.url
.as_ref()
.map(|url| OnOpened::Document { url: url.clone() });
// Set the `on_opened` field as it won't be returned from Coco server
document.on_opened = on_opened;
hits.push((document, score));
}
}
Err(err) => {
// Handle error from the request itself
Err(SearchError::HttpError(err.to_string()))
}
}
// Return the final result
Ok(QueryResponse {
source: self.get_type(),
hits,
total_hits,
})
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,57 @@
use crate::server::http_client::HttpClient;
use futures_util::StreamExt;
use http::Method;
use serde_json::json;
use tauri::{AppHandle, Emitter, command};
#[command]
pub async fn synthesize(
app_handle: AppHandle,
client_id: String,
server_id: String,
voice: String,
content: String,
) -> Result<(), String> {
let body = json!({
"voice": voice,
"content": content,
})
.to_string();
let response = HttpClient::send_request(
server_id.as_str(),
Method::POST,
"/services/audio/synthesize",
None,
None,
Some(reqwest::Body::from(body.to_string())),
)
.await?;
log::info!("Synthesize response status: {}", response.status());
if response.status() == 429 {
return Ok(());
}
if !response.status().is_success() {
return Err(format!("Request Failed: {}", response.status()));
}
let mut stream = response.bytes_stream();
while let Some(chunk) = stream.next().await {
match chunk {
Ok(bytes) => {
if let Err(err) = app_handle.emit(&client_id, bytes.to_vec()) {
log::error!("Emit error: {:?}", err);
}
}
Err(e) => {
log::error!("Stream error: {:?}", e);
break;
}
}
}
Ok(())
}

View File

@@ -0,0 +1,15 @@
use crate::server::http_client::HttpClient;
use serde_json::Value;
use tauri::command;
#[command]
pub async fn get_system_settings(server_id: String) -> Result<Value, String> {
let response = HttpClient::get(&server_id, "/settings", None)
.await
.map_err(|err| err.to_string())?;
response
.json::<Value>()
.await
.map_err(|err| err.to_string())
}

View File

@@ -0,0 +1,96 @@
use crate::common::http::get_response_body_text;
use crate::server::http_client::HttpClient;
use serde::{Deserialize, Serialize};
use serde_json::{Value, from_str};
use tauri::command;
#[derive(Debug, Serialize, Deserialize)]
pub struct TranscriptionResponse {
task_id: String,
results: Vec<Value>,
}
#[command]
pub async fn transcription(
server_id: String,
audio_content: String,
) -> Result<TranscriptionResponse, String> {
// Send request to initiate transcription task
let init_response = HttpClient::post(
&server_id,
"/services/audio/transcription",
None,
Some(audio_content.into()),
)
.await
.map_err(|e| format!("Failed to initiate transcription: {}", e))?;
// Extract response body as text
let init_response_text = get_response_body_text(init_response)
.await
.map_err(|e| format!("Failed to read initial response body: {}", e))?;
// Parse response JSON to extract task ID
let init_response_json: Value = from_str(&init_response_text).map_err(|e| {
format!(
"Failed to parse initial response JSON: {}. Raw response: {}",
e, init_response_text
)
})?;
let transcription_task_id = init_response_json["task_id"]
.as_str()
.ok_or_else(|| {
format!(
"Missing or invalid task_id in initial response: {}",
init_response_text
)
})?
.to_string();
// Set up polling with timeout
let polling_start = std::time::Instant::now();
const POLLING_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
const POLLING_INTERVAL: std::time::Duration = std::time::Duration::from_millis(200);
let mut transcription_response: TranscriptionResponse;
loop {
// Poll for transcription results
let poll_response = HttpClient::get(
&server_id,
&format!("/services/audio/task/{}", transcription_task_id),
None,
)
.await
.map_err(|e| format!("Failed to poll transcription task: {}", e))?;
// Extract poll response body
let poll_response_text = get_response_body_text(poll_response)
.await
.map_err(|e| format!("Failed to read poll response body: {}", e))?;
// Parse poll response JSON
transcription_response = from_str(&poll_response_text).map_err(|e| {
format!(
"Failed to parse poll response JSON: {}. Raw response: {}",
e, poll_response_text
)
})?;
// Check if transcription results are available
if !transcription_response.results.is_empty() {
break;
}
// Check for timeout
if polling_start.elapsed() >= POLLING_TIMEOUT {
return Err("Transcription task timed out after 30 seconds".to_string());
}
// Wait before next poll
tokio::time::sleep(POLLING_INTERVAL).await;
}
Ok(transcription_response)
}

View File

@@ -1,184 +0,0 @@
use crate::server::servers::{get_server_by_id, get_server_token};
use futures_util::{SinkExt, StreamExt};
use http::{HeaderMap, HeaderName, HeaderValue};
use std::sync::Arc;
use tauri::Emitter;
use tokio::net::TcpStream;
use tokio::sync::{mpsc, Mutex};
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
use tokio_tungstenite::tungstenite::Error;
use tokio_tungstenite::tungstenite::Error as WsError;
use tokio_tungstenite::{
connect_async, tungstenite::protocol::Message, MaybeTlsStream, WebSocketStream,
};
use tungstenite::handshake::client::generate_key;
#[derive(Default)]
pub struct WebSocketManager {
ws_connection: Arc<Mutex<Option<WebSocketStream<MaybeTlsStream<TcpStream>>>>>,
cancel_tx: Arc<Mutex<Option<mpsc::Sender<()>>>>,
}
// Function to convert the HTTP endpoint to WebSocket endpoint
fn convert_to_websocket(endpoint: &str) -> Result<String, String> {
let url = url::Url::parse(endpoint).map_err(|e| format!("Invalid URL: {}", e))?;
// Determine WebSocket protocol based on the scheme
let ws_protocol = if url.scheme() == "https" {
"wss://"
} else {
"ws://"
};
// Extract host and port (if present)
let host = url.host_str().ok_or_else(|| "No host found in URL")?;
let port = url
.port_or_known_default()
.unwrap_or(if url.scheme() == "https" { 443 } else { 80 });
// Build WebSocket URL, include the port if not the default
let ws_endpoint = if port == 80 || port == 443 {
format!("{}{}{}", ws_protocol, host, "/ws")
} else {
format!("{}{}:{}/ws", ws_protocol, host, port)
};
Ok(ws_endpoint)
}
// Function to build a HeaderMap from a vector of key-value pairs
fn build_header_map(headers: Vec<(String, String)>) -> Result<HeaderMap, String> {
let mut header_map = HeaderMap::new();
for (key, value) in headers {
let header_name = HeaderName::from_bytes(key.as_bytes())
.map_err(|e| format!("Invalid header name: {}", e))?;
let header_value =
HeaderValue::from_str(&value).map_err(|e| format!("Invalid header value: {}", e))?;
header_map.insert(header_name, header_value);
}
Ok(header_map)
}
#[tauri::command]
pub async fn connect_to_server(
id: String,
state: tauri::State<'_, WebSocketManager>,
app_handle: tauri::AppHandle,
) -> Result<(), String> {
// Disconnect any existing connection first
disconnect(state.clone()).await?;
// Retrieve server details
let server =
get_server_by_id(id.as_str()).ok_or_else(|| format!("Server with ID {} not found", id))?;
let endpoint = convert_to_websocket(server.endpoint.as_str())?;
// Retrieve the token for the server (token is optional)
let token = get_server_token(id.as_str()).map(|t| t.access_token.clone());
// Create the WebSocket request
let mut request =
tokio_tungstenite::tungstenite::client::IntoClientRequest::into_client_request(&endpoint)
.map_err(|e| format!("Failed to create WebSocket request: {}", e))?;
// Add necessary headers
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 a token exists, add it to the headers
if let Some(token) = token {
request
.headers_mut()
.insert("X-API-TOKEN", token.parse().unwrap());
}
// Establish the WebSocket connection
// dbg!(&request);
let (mut ws_remote, _) = connect_async(request).await.map_err(|e| match e {
Error::ConnectionClosed => "WebSocket connection was closed".to_string(),
Error::Protocol(protocol_error) => format!("Protocol error: {}", protocol_error),
Error::Utf8 => "UTF-8 error in WebSocket data".to_string(),
_ => format!("Unknown error: {:?}", e),
})?;
// Create cancellation channel
let (cancel_tx, mut cancel_rx) = mpsc::channel(1);
// Store connection and cancellation sender
*state.ws_connection.lock().await = Some(ws_remote);
*state.cancel_tx.lock().await = Some(cancel_tx);
// Spawn listener task with cancellation
let app_handle_clone = app_handle.clone();
let connection_clone = state.ws_connection.clone();
tokio::spawn(async move {
let mut connection = connection_clone.lock().await;
if let Some(ws) = connection.as_mut() {
loop {
tokio::select! {
msg = ws.next() => {
match msg {
Some(Ok(Message::Text(text))) => {
//println!("Received message: {}", text);
let _ = app_handle_clone.emit("ws-message", text);
},
Some(Err(WsError::ConnectionClosed)) => {
let _ = app_handle_clone.emit("ws-error", id);
eprintln!("WebSocket connection closed by the server.");
break;
},
Some(Err(WsError::Protocol(e))) => {
let _ = app_handle_clone.emit("ws-error", id);
eprintln!("Protocol error: {}", e);
break;
},
Some(Err(WsError::Utf8)) => {
let _ = app_handle_clone.emit("ws-error", id);
eprintln!("Received invalid UTF-8 data.");
break;
},
Some(Err(_)) => {
let _ = app_handle_clone.emit("ws-error", id);
eprintln!("WebSocket error encountered.");
break;
},
_ => continue,
}
}
_ = cancel_rx.recv() => {
let _ = app_handle_clone.emit("ws-error", id);
dbg!("Cancelling WebSocket connection");
break;
}
}
}
}
});
Ok(())
}
#[tauri::command]
pub async fn disconnect(state: tauri::State<'_, WebSocketManager>) -> Result<(), String> {
// Send cancellation signal
if let Some(cancel_tx) = state.cancel_tx.lock().await.take() {
let _ = cancel_tx.send(()).await;
}
// Close connection
let mut connection = state.ws_connection.lock().await;
if let Some(mut ws) = connection.take() {
let _ = ws.close(None).await;
}
Ok(())
}

72
src-tauri/src/settings.rs Normal file
View File

@@ -0,0 +1,72 @@
use crate::COCO_TAURI_STORE;
use serde_json::Value as Json;
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(tauri_app_handle: AppHandle, value: bool) {
use crate::server::http_client;
let store = tauri_app_handle
.store(COCO_TAURI_STORE)
.unwrap_or_else(|e| {
panic!(
"store [{}] not found/loaded, error [{}]",
COCO_TAURI_STORE, e
)
});
let old_value = match store
.get(SETTINGS_ALLOW_SELF_SIGNATURE)
.expect("should be initialized upon first get call")
{
Json::Bool(b) => b,
_ => unreachable!(
"{} should be stored in a boolean",
SETTINGS_ALLOW_SELF_SIGNATURE
),
};
if old_value == value {
return;
}
store.set(SETTINGS_ALLOW_SELF_SIGNATURE, value);
let mut guard = http_client::HTTP_CLIENT.lock().await;
*guard = http_client::new_reqwest_http_client(value)
}
/// Synchronous version of `async get_allow_self_signature()`.
pub fn _get_allow_self_signature(tauri_app_handle: AppHandle) -> bool {
let store = tauri_app_handle
.store(COCO_TAURI_STORE)
.unwrap_or_else(|e| {
panic!(
"store [{}] not found/loaded, error [{}]",
COCO_TAURI_STORE, e
)
});
if !store.has(SETTINGS_ALLOW_SELF_SIGNATURE) {
// default to false
store.set(SETTINGS_ALLOW_SELF_SIGNATURE, false);
}
match store
.get(SETTINGS_ALLOW_SELF_SIGNATURE)
.expect("should be Some")
{
Json::Bool(b) => b,
_ => unreachable!(
"{} should be stored in a boolean",
SETTINGS_ALLOW_SELF_SIGNATURE
),
}
}
#[tauri::command]
pub async fn get_allow_self_signature(tauri_app_handle: AppHandle) -> bool {
_get_allow_self_signature(tauri_app_handle)
}

View File

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

View File

@@ -1,9 +1,7 @@
//credits to: https://github.com/ayangweb/ayangweb-EcoPaste/blob/169323dbe6365ffe4abb64d867439ed2ea84c6d1/src-tauri/src/core/setup/mac.rs
use tauri::{ActivationPolicy, App, Emitter, EventTarget, WebviewWindow};
use tauri_nspanel::{
cocoa::appkit::{NSMainMenuWindowLevel, NSWindowCollectionBehavior},
panel_delegate, WebviewWindowExt,
};
//! credits to: https://github.com/ayangweb/ayangweb-EcoPaste/blob/169323dbe6365ffe4abb64d867439ed2ea84c6d1/src-tauri/src/core/setup/mac.rs
use tauri::{AppHandle, Emitter, EventTarget, WebviewWindow};
use tauri_nspanel::{WebviewWindowExt, cocoa::appkit::NSWindowCollectionBehavior, panel_delegate};
use crate::common::MAIN_WINDOW_LABEL;
@@ -15,21 +13,24 @@ const WINDOW_BLUR_EVENT: &str = "tauri://blur";
const WINDOW_MOVED_EVENT: &str = "tauri://move";
const WINDOW_RESIZED_EVENT: &str = "tauri://resize";
pub fn platform(app: &mut App, main_window: WebviewWindow, _settings_window: WebviewWindow) {
app.set_activation_policy(ActivationPolicy::Accessory);
pub fn platform(
_tauri_app_handle: &AppHandle,
main_window: WebviewWindow,
_settings_window: WebviewWindow,
_check_window: WebviewWindow,
) {
// Convert ns_window to ns_panel
let panel = main_window.to_panel().unwrap();
// Make the window above the dock
panel.set_level(NSMainMenuWindowLevel + 1);
panel.set_level(20);
// 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::NSWindowCollectionBehaviorCanJoinAllSpaces
NSWindowCollectionBehavior::NSWindowCollectionBehaviorMoveToActiveSpace
| NSWindowCollectionBehavior::NSWindowCollectionBehaviorStationary
| NSWindowCollectionBehavior::NSWindowCollectionBehaviorFullScreenAuxiliary,
);

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;
@@ -18,10 +23,77 @@ pub use windows::*;
#[cfg(target_os = "linux")]
pub use linux::*;
pub fn default(app: &mut App, main_window: WebviewWindow, settings_window: WebviewWindow) {
pub fn default(
tauri_app_handle: &AppHandle,
main_window: WebviewWindow,
settings_window: WebviewWindow,
check_window: WebviewWindow,
) {
// Development mode automatically opens the console: https://tauri.app/develop/debug
#[cfg(any(dev, debug_assertions))]
#[cfg(debug_assertions)]
main_window.open_devtools();
platform(app, main_window.clone(), settings_window.clone());
platform(
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,3 +1,9 @@
use tauri::{App, WebviewWindow};
use tauri::{AppHandle, WebviewWindow};
pub fn platform(_app: &mut App, _main_window: WebviewWindow, _settings_window: WebviewWindow) {}
pub fn platform(
_tauri_app_handle: &AppHandle,
_main_window: WebviewWindow,
_settings_window: WebviewWindow,
_check_window: WebviewWindow,
) {
}

View File

@@ -1,13 +1,8 @@
use crate::{move_window_to_active_monitor, COCO_TAURI_STORE};
use tauri::App;
use tauri::AppHandle;
use tauri::Manager;
use tauri::Runtime;
use tauri_plugin_global_shortcut::GlobalShortcutExt;
use tauri_plugin_global_shortcut::Shortcut;
use tauri_plugin_global_shortcut::ShortcutState;
use tauri_plugin_store::JsonValue;
use tauri_plugin_store::StoreExt;
use crate::common::MAIN_WINDOW_LABEL;
use crate::{COCO_TAURI_STORE, hide_coco, show_coco};
use tauri::{AppHandle, Manager, async_runtime};
use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState};
use tauri_plugin_store::{JsonValue, StoreExt};
/// Tauri's store is a key-value database, we use it to store our registered
/// global shortcut.
@@ -22,8 +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) {
let store = app
pub fn enable_shortcut(tauri_app_handle: &AppHandle) {
log::trace!("setting up Coco hotkey");
let store = tauri_app_handle
.store(COCO_TAURI_STORE)
.expect("creating a store should not fail");
@@ -38,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,
@@ -47,21 +43,22 @@ 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");
}
/// Get the stored shortcut as a string, same as [`_get_shortcut()`], except that
/// this is a `tauri::command` interface.
#[tauri::command]
pub 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 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>()
@@ -74,9 +71,9 @@ pub fn unregister_shortcut<R: Runtime>(app: AppHandle<R>) {
/// Change the global shortcut to `key`.
#[tauri::command]
pub 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);
@@ -98,23 +95,22 @@ pub 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 {
dbg!("shortcut pressed");
let main_window = app.get_window(MAIN_WINDOW_LABEL).unwrap();
let main_window = app.get_webview_window(MAIN_WINDOW_LABEL).unwrap();
if let ShortcutState::Pressed = event.state() {
let app_handle = app.clone();
if main_window.is_visible().unwrap() {
dbg!("hiding window");
main_window.hide().unwrap();
async_runtime::spawn(async move {
hide_coco(app_handle).await;
});
} else {
dbg!("showing window");
move_window_to_active_monitor(&main_window);
main_window.set_visible_on_all_workspaces(true).unwrap();
main_window.set_always_on_top(true).unwrap();
main_window.set_focus().unwrap();
main_window.show().unwrap();
async_runtime::spawn(async move {
show_coco(app_handle).await;
});
}
}
}
@@ -123,27 +119,25 @@ 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| {
if scut == &shortcut {
let window = app.get_window(MAIN_WINDOW_LABEL).unwrap();
let window = app.get_webview_window(MAIN_WINDOW_LABEL).unwrap();
if let ShortcutState::Pressed = event.state() {
let app_handle = app.clone();
if window.is_visible().unwrap() {
window.hide().unwrap();
async_runtime::spawn(async move {
hide_coco(app_handle).await;
});
} else {
// dbg!("showing window");
move_window_to_active_monitor(&window);
window.set_visible_on_all_workspaces(true).unwrap();
window.set_always_on_top(true).unwrap();
window.set_focus().unwrap();
window.show().unwrap();
async_runtime::spawn(async move {
show_coco(app_handle).await;
});
}
}
}
@@ -151,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

@@ -0,0 +1,62 @@
//! Configuration entry App language is persisted in the frontend code, but we
//! need to access it on the backend.
//!
//! 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 tokio::sync::RwLock;
#[derive(Debug, Clone, Copy, PartialEq)]
#[allow(non_camel_case_types)]
pub(crate) enum Lang {
en_US,
zh_CN,
}
impl std::fmt::Display for Lang {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Lang::en_US => write!(f, "en_US"),
Lang::zh_CN => write!(f, "zh_CN"),
}
}
}
/// 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;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"en" => Ok(Lang::en_US),
"zh" => Ok(Lang::zh_CN),
_ => Err(format!("Invalid language: {}", s)),
}
}
}
/// Cache the language config in memory.
static APP_LANG: RwLock<Option<Lang>> = RwLock::const_new(None);
/// 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!(
"invalid argument [{}], could not parse it to [struct Lang], parsing error [{}]",
lang, e
)
});
let mut write_guard = APP_LANG.write().await;
*write_guard = Some(app_lang);
}
/// Helper getter method to handle the `None` case.
pub(crate) async fn get_app_lang() -> Lang {
let opt_lang = *APP_LANG.read().await;
opt_lang.expect("frontend code did not invoke [update_app_lang()] to set the APP_LANG")
}

174
src-tauri/src/util/file.rs Normal file
View File

@@ -0,0 +1,174 @@
#[derive(Debug, Clone, PartialEq, Copy)]
pub(crate) enum FileType {
Folder,
JPEGImage,
PNGImage,
PDFDocument,
PlainTextDocument,
MicrosoftWordDocument,
MicrosoftExcelSpreadsheet,
AudioFile,
VideoFile,
CHeaderFile,
TOMLDocument,
RustScript,
CSourceCode,
MarkdownDocument,
TerminalSettings,
ZipArchive,
Dmg,
Html,
Json,
Xml,
Yaml,
Css,
Vue,
React,
Sql,
Csv,
Javascript,
Lnk,
Typescript,
Python,
Java,
Golang,
Ruby,
Php,
Sass,
Sketch,
AdobeAi,
AdobePsd,
AdobePr,
AdobeAu,
AdobeAe,
AdobeLr,
AdobeXd,
AdobeFl,
AdobeId,
Svg,
Epub,
Unknown,
}
async fn get_file_type(path: &str) -> FileType {
let path = camino::Utf8Path::new(path);
// stat() is more precise than file extension, use it if possible.
if path.is_dir() {
return FileType::Folder;
}
let Some(ext) = path.extension() else {
return FileType::Unknown;
};
let ext = ext.to_lowercase();
match ext.as_str() {
"pdf" => FileType::PDFDocument,
"txt" | "text" => FileType::PlainTextDocument,
"doc" | "docx" => FileType::MicrosoftWordDocument,
"xls" | "xlsx" => FileType::MicrosoftExcelSpreadsheet,
"jpg" | "jpeg" => FileType::JPEGImage,
"png" => FileType::PNGImage,
"mp3" | "wav" | "flac" | "aac" | "ogg" | "m4a" => FileType::AudioFile,
"mp4" | "avi" | "mov" | "mkv" | "wmv" | "flv" | "webm" => FileType::VideoFile,
"h" | "hpp" => FileType::CHeaderFile,
"c" | "cpp" | "cc" | "cxx" => FileType::CSourceCode,
"toml" => FileType::TOMLDocument,
"rs" => FileType::RustScript,
"md" | "markdown" => FileType::MarkdownDocument,
"terminal" => FileType::TerminalSettings,
"zip" | "rar" | "7z" | "tar" | "gz" | "bz2" => FileType::ZipArchive,
"dmg" => FileType::Dmg,
"html" | "htm" => FileType::Html,
"json" => FileType::Json,
"xml" => FileType::Xml,
"yaml" | "yml" => FileType::Yaml,
"css" => FileType::Css,
"vue" => FileType::Vue,
"jsx" | "tsx" => FileType::React,
"sql" => FileType::Sql,
"csv" => FileType::Csv,
"js" | "mjs" => FileType::Javascript,
"ts" => FileType::Typescript,
"py" | "pyw" => FileType::Python,
"java" => FileType::Java,
"go" => FileType::Golang,
"rb" => FileType::Ruby,
"php" => FileType::Php,
"sass" | "scss" => FileType::Sass,
"sketch" => FileType::Sketch,
"ai" => FileType::AdobeAi,
"psd" => FileType::AdobePsd,
"prproj" => FileType::AdobePr,
"aup" | "aup3" => FileType::AdobeAu,
"aep" => FileType::AdobeAe,
"lrcat" => FileType::AdobeLr,
"xd" => FileType::AdobeXd,
"fla" => FileType::AdobeFl,
"indd" => FileType::AdobeId,
"svg" => FileType::Svg,
"epub" => FileType::Epub,
"lnk" => FileType::Lnk,
_ => FileType::Unknown,
}
}
fn type_to_icon(ty: FileType) -> &'static str {
match ty {
FileType::Folder => "font_file_folder",
FileType::JPEGImage => "font_file_image",
FileType::PNGImage => "font_file_image",
FileType::PDFDocument => "font_file_document_pdf",
FileType::PlainTextDocument => "font_file_txt",
FileType::MicrosoftWordDocument => "font_file_document_word",
FileType::MicrosoftExcelSpreadsheet => "font_file_spreadsheet_excel",
FileType::AudioFile => "font_file_audio",
FileType::VideoFile => "font_file_video",
FileType::CHeaderFile => "font_file_csource",
FileType::TOMLDocument => "font_file_toml",
FileType::RustScript => "font_file_rustscript1",
FileType::CSourceCode => "font_file_csource",
FileType::MarkdownDocument => "font_file_markdown",
FileType::TerminalSettings => "font_file_terminal1",
FileType::ZipArchive => "font_file_zip",
FileType::Dmg => "font_file_dmg",
FileType::Html => "font_file_html",
FileType::Json => "font_file_json",
FileType::Xml => "font_file_xml",
FileType::Yaml => "font_file_yaml",
FileType::Css => "font_file_css",
FileType::Vue => "font_file_vue",
FileType::React => "font_file_react",
FileType::Sql => "font_file_sql",
FileType::Csv => "font_file_csv",
FileType::Javascript => "font_file_javascript",
FileType::Lnk => "font_file_lnk",
FileType::Typescript => "font_file_typescript",
FileType::Python => "font_file_python",
FileType::Java => "font_file_java",
FileType::Golang => "font_file_golang",
FileType::Ruby => "font_file_ruby",
FileType::Php => "font_file_php",
FileType::Sass => "font_file_sass",
FileType::Sketch => "font_file_sketch",
FileType::AdobeAi => "font_file_adobe_ai",
FileType::AdobePsd => "font_file_adobe_psd",
FileType::AdobePr => "font_file_adobe_pr",
FileType::AdobeAu => "font_file_adobe_au",
FileType::AdobeAe => "font_file_adobe_ae",
FileType::AdobeLr => "font_file_adobe_lr",
FileType::AdobeXd => "font_file_adobe_xd",
FileType::AdobeFl => "font_file_adobe_fl",
FileType::AdobeId => "font_file_adobe_id",
FileType::Svg => "font_file_svg",
FileType::Epub => "font_file_epub",
FileType::Unknown => "font_file_unknown",
}
}
#[tauri::command]
pub(crate) async fn get_file_icon(path: String) -> &'static str {
let ty = get_file_type(path.as_str()).await;
type_to_icon(ty)
}

View File

@@ -0,0 +1,159 @@
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;
use tauri_plugin_shell::ShellExt;
/// We use this env variable to determine the DE on Linux.
const XDG_CURRENT_DESKTOP: &str = "XDG_CURRENT_DESKTOP";
#[derive(Debug, PartialEq)]
enum LinuxDesktopEnvironment {
Gnome,
Kde,
Unsupported { xdg_current_desktop: String },
}
impl LinuxDesktopEnvironment {
// This impl is based on: https://wiki.archlinux.org/title/Desktop_entries#Usage
fn launch_app_via_desktop_file<P: AsRef<Path>>(&self, file: P) -> Result<(), String> {
let path = file.as_ref();
if !path.try_exists().map_err(|e| e.to_string())? {
return Err(format!("desktop file [{}] does not exist", path.display()));
}
let cmd_output = match self {
Self::Gnome => {
let uri = path
.file_stem()
.expect("the desktop file should contain a file stem part");
Command::new("gtk-launch")
.arg(uri)
.output()
.map_err(|e| e.to_string())?
}
Self::Kde => Command::new("kde-open")
.arg(path)
.output()
.map_err(|e| e.to_string())?,
Self::Unsupported {
xdg_current_desktop,
} => {
return Err(format!(
"Cannot open apps as this Linux desktop environment [{}] is not supported",
xdg_current_desktop
));
}
};
if !cmd_output.status.success() {
return Err(format!(
"failed to launch app via desktop file [{}], underlying command stderr [{}]",
path.display(),
String::from_utf8_lossy(&cmd_output.stderr)
));
}
Ok(())
}
}
/// None means that it is likely that we do not have a desktop environment.
fn get_linux_desktop_environment() -> Option<LinuxDesktopEnvironment> {
let de_os_str = std::env::var_os(XDG_CURRENT_DESKTOP)?;
let de_str = de_os_str.into_string().unwrap_or_else(|_os_string| {
panic!("${} should be UTF-8 encoded", XDG_CURRENT_DESKTOP);
});
let de = match de_str.as_str() {
"GNOME" => LinuxDesktopEnvironment::Gnome,
// Ubuntu uses "ubuntu:GNOME" instead of just "GNOME", they really love
// their distro name.
"ubuntu:GNOME" => LinuxDesktopEnvironment::Gnome,
"KDE" => LinuxDesktopEnvironment::Kde,
_ => LinuxDesktopEnvironment::Unsupported {
xdg_current_desktop: de_str,
},
};
Some(de)
}
/// Homemade open() function to support open Linux applications via the `.desktop` file.
//
// tauri_plugin_shell::open() is deprecated, but we still use it.
#[allow(deprecated)]
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() {
if file_extension == "desktop" {
let desktop_environment = get_linux_desktop_environment().expect("The Linux OS is running without a desktop, Coco could never run in such an environment");
return desktop_environment.launch_app_via_desktop_file(path);
}
}
}
app_handle
.shell()
.open(path, None)
.map_err(|e| e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
// This test modifies env var XDG_CURRENT_DESKTOP, which is kinda unsafe
// but considering this is just test, it is ok to do so.
#[test]
fn test_get_linux_desktop_environment() {
// SAFETY: Rust code won't modify/read XDG_CURRENT_DESKTOP concurrently, we
// have no guarantee from the underlying C code.
unsafe {
// Save the original value if it exists
let original_value = std::env::var_os(XDG_CURRENT_DESKTOP);
// Test when XDG_CURRENT_DESKTOP is not set
std::env::remove_var(XDG_CURRENT_DESKTOP);
assert!(get_linux_desktop_environment().is_none());
// Test GNOME
std::env::set_var(XDG_CURRENT_DESKTOP, "GNOME");
let result = get_linux_desktop_environment();
assert_eq!(result.unwrap(), LinuxDesktopEnvironment::Gnome);
// Test ubuntu:GNOME
std::env::set_var(XDG_CURRENT_DESKTOP, "ubuntu:GNOME");
let result = get_linux_desktop_environment();
assert_eq!(result.unwrap(), LinuxDesktopEnvironment::Gnome);
// Test KDE
std::env::set_var(XDG_CURRENT_DESKTOP, "KDE");
let result = get_linux_desktop_environment();
assert_eq!(result.unwrap(), LinuxDesktopEnvironment::Kde);
// Test unsupported desktop environment
std::env::set_var(XDG_CURRENT_DESKTOP, "XFCE");
let result = get_linux_desktop_environment();
assert_eq!(
result.unwrap(),
LinuxDesktopEnvironment::Unsupported {
xdg_current_desktop: "XFCE".into()
}
);
// Restore the original value
match original_value {
Some(value) => std::env::set_var(XDG_CURRENT_DESKTOP, value),
None => std::env::remove_var(XDG_CURRENT_DESKTOP),
}
}
}
}

View File

@@ -0,0 +1,61 @@
use derive_more::Display;
use serde::{Deserialize, Serialize};
use std::borrow::Cow;
use strum::EnumCount;
use strum::VariantArray;
#[derive(
Debug,
Deserialize,
Serialize,
Copy,
Clone,
Hash,
PartialEq,
Eq,
Display,
EnumCount,
VariantArray,
)]
#[serde(rename_all(serialize = "lowercase", deserialize = "lowercase"))]
pub(crate) enum Platform {
#[display("macOS")]
Macos,
#[display("Linux")]
Linux,
#[display("windows")]
Windows,
}
impl Platform {
/// Helper function to determine the current platform.
pub(crate) fn current() -> Platform {
let os_str = std::env::consts::OS;
serde_plain::from_str(os_str).unwrap_or_else(|_e| {
panic!("std::env::consts::OS is [{}], which is not a valid value for [enum Platform], valid values: {:?}", os_str, Self::VARIANTS.iter().map(|platform|platform.to_string()).collect::<Vec<String>>());
})
}
/// Return the `X-OS-NAME` HTTP request header.
pub(crate) fn to_os_name_http_header_str(&self) -> Cow<'static, str> {
match self {
Self::Macos => Cow::Borrowed("macos"),
Self::Windows => Cow::Borrowed("windows"),
// For Linux, we need the actual distro `ID`, not just a "linux".
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('-', "_")
}

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