17 Commits

Author SHA1 Message Date
Hardy
b5f5d3bd28 chore: update release notes for publish 0.9.1-2602 (#1004)
Co-authored-by: github-actions <github-actions@github.com>
2025-12-05 19:58:09 +08:00
BiggerRain
378d5aef69 chore: hide selection function (#1003) 2025-12-05 17:58:40 +08:00
SteveLauC
10ee3cd9d3 chore: bump version to 0.9.1 (#1001) 2025-12-05 15:43:59 +08:00
BiggerRain
97d2450fa7 feat: selection settings add & delete (#992)
* feat: add selection window page

* fix: chat input

* feat: add selection page

* chore: add

* chore: test

* feat: add

* feat: add store

* feat: add selection settings

* chore: remove unused code

* docs: add release note

* docs: add release note

* chore: format code

* chore: format code

* fix: copy error

* disable hashbrown default feature

* Enable unstable feature allocator_api

To make coco-app compile in CI:

```
--> /home/runner/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/hashbrown-0.15.5/src/raw/mod.rs:3856:12
     |
3856 | impl<T, A: Allocator> RawIntoIter<T, A> {
     |            ^^^^^^^^^
     |
     = note: see issue #32838 <https://github.com/rust-lang/rust/issues/32838> for more information
     = help: add `#![feature(allocator_api)]` to the crate attributes to enable
     = note: this compiler was built on 2025-06-25; consider upgrading it if it is out of date
```

I don't know why it does not compile, feature `allocator-api2` is
enabled for `hashbrown 0.15.5`, so technically [1] it should not use the
allocator APIs from the std. According to [2], enabling the `nightly`
feature of `allocator-api2` may cause this issue as well, but it is not
enabled in our case either.

Anyway, enabling `#![feature(allocator_api)]` should make it work.

[1]: b751eef8e9/src/raw/alloc.rs (L26-L47)
[2]: https://github.com/rust-lang/hashbrown/issues/564

* put it in main.rs

* format main.rs

* Enable default-features for hashbrown 0.15.5

* format main.rs

* enable feature allocator-api2

* feat: add selection set config

* fix: selection setting

* fix: ci error

* fix: ci error

* fix: ci error

* fix: ci error

* merge: merge main

* fix: rust code warn

* fix: rust code error

* fix: rust code error

* fix: selection settings

* style: selection styles

* style: selection styles

* feat: selection settings add & delete

* feat: selection settings add & delete

* feat: selection settings add & delete

* style: selection styles

* chore: add @tauri-store/zustand plugin

* refactor: the selection store using @tauri-store/zustand

* fix: data error

* fix: data error

* chore: remove config

* chore: selection

* chore: selection

* chore: width

* chore: ignore selection in the app itself

* style: selection styles

* style: remove

* docs: add notes

* chore: add permission check

* chore: selection

* chore: style & store

---------

Co-authored-by: Steve Lau <stevelauc@outlook.com>
2025-12-05 15:32:57 +08:00
BiggerRain
18828ab043 chore: modify the display of errors (#1000) 2025-12-05 15:15:42 +08:00
BiggerRain
3b80eb77b4 chore: adjust the position of the compact mode window (#997)
* chore: adjust the position of the compact mode window

* docs: update release note

* chore: adjust code

* chore: adjust code
2025-12-05 10:46:37 +08:00
9paiii
6e2514adbd docs: update coco app documentation (#996)
* Update Coco App documentation

Add detailed descriptions for all major Coco App features

* Title Corrected

* Fix image path
2025-12-05 10:35:19 +08:00
BiggerRain
82074ab46a chore: change the default port of the tauri project (#999) 2025-12-05 09:38:54 +08:00
BiggerRain
8158d49050 fix: no request to the assistant interface (#998)
* fix: no request to the assistant interface

* Refactor comment for clarity in AssistantFetcher
2025-12-04 16:43:22 +08:00
SteveLauC
3052878869 refactor: error handling in install_extension interfaces (#995)
* refactor: error handling in install_extension interfaces

* fmt

* fix build

* release notes
2025-12-02 15:39:44 +08:00
BiggerRain
73ca224ad8 refactor: the selection store using @tauri-store/zustand (#994)
* chore: add @tauri-store/zustand plugin

* refactor: the selection store using @tauri-store/zustand

* fix: data error

* fix: data error

* chore: remove config
2025-11-28 17:29:36 +08:00
BiggerRain
ff7721d17f feat: add selection toolbar window for mac (#980)
* feat: add selection window page

* fix: chat input

* feat: add selection page

* chore: add

* chore: test

* feat: add

* feat: add store

* feat: add selection settings

* chore: remove unused code

* docs: add release note

* docs: add release note

* chore: format code

* chore: format code

* fix: copy error

* disable hashbrown default feature

* Enable unstable feature allocator_api

To make coco-app compile in CI:

```
--> /home/runner/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/hashbrown-0.15.5/src/raw/mod.rs:3856:12
     |
3856 | impl<T, A: Allocator> RawIntoIter<T, A> {
     |            ^^^^^^^^^
     |
     = note: see issue #32838 <https://github.com/rust-lang/rust/issues/32838> for more information
     = help: add `#![feature(allocator_api)]` to the crate attributes to enable
     = note: this compiler was built on 2025-06-25; consider upgrading it if it is out of date
```

I don't know why it does not compile, feature `allocator-api2` is
enabled for `hashbrown 0.15.5`, so technically [1] it should not use the
allocator APIs from the std. According to [2], enabling the `nightly`
feature of `allocator-api2` may cause this issue as well, but it is not
enabled in our case either.

Anyway, enabling `#![feature(allocator_api)]` should make it work.

[1]: b751eef8e9/src/raw/alloc.rs (L26-L47)
[2]: https://github.com/rust-lang/hashbrown/issues/564

* put it in main.rs

* format main.rs

* Enable default-features for hashbrown 0.15.5

* format main.rs

* enable feature allocator-api2

* feat: add selection set config

* fix: selection setting

* fix: ci error

* fix: ci error

* fix: ci error

* fix: ci error

* merge: merge main

* fix: rust code warn

* fix: rust code error

* fix: rust code error

* fix: selection settings

* style: selection styles

* style: selection styles

---------

Co-authored-by: Steve Lau <stevelauc@outlook.com>
2025-11-27 10:12:49 +08:00
BiggerRain
fc642ac0e3 fix: remove eventListener (#993) 2025-11-27 09:33:22 +08:00
SteveLauC
d5d616ade9 chore: update outdated code comments (#991)
* chore: update outdated code comments

To initialize the app language stored in the backend, the frontend no
longer uses update_app_lang(). Instead, it now uses backend_setup().

* format code
2025-11-25 17:40:30 +08:00
ayangweb
96977be623 refactor: auto-focus input field when switching to compact mode (#990) 2025-11-25 11:07:26 +08:00
SteveLauC
d8a1b9b9c6 feat: add a heartbeat worker to check Coco server availability (#988)
* feat: add a heartbeat worker to check Coco server availability

* relase notes
2025-11-25 10:20:33 +08:00
SteveLauC
f83b1ba2a7 chore: write panic message to stdout in panic hook (#989)
This commit updates the panic hook implementation to log the panic
message to stdout as well, which would make debugging easier. It is
hightly possible that a panic may get ignored by us when we run Coco
app in the dev mode (since the hook only writes msg to the panic log file).
2025-11-25 08:56:29 +08:00
127 changed files with 6104 additions and 1594 deletions

View File

@@ -14,6 +14,7 @@
"dyld",
"elif",
"errmsg",
"frontmost",
"fullscreen",
"fulltext",
"headlessui",
@@ -40,6 +41,7 @@
"nowrap",
"nspanel",
"nsstring",
"objc",
"overscan",
"partialize",
"patchelf",

View File

@@ -0,0 +1,59 @@
---
weight: 1
title: AI Overview
---
# AI Overview
The **AI Overview** feature can automatically refine and summarize current search results in search mode, helping users quickly grasp the key points of the search results without having to browse each individual result. This feature is particularly useful in scenarios where information needs to be extracted quickly.
{{% load-img "/img/core-features/ai_overview_01.png" "" %}}
## Feature Overview
- **Automatic Refinement and Summary**: When a user performs a search, AI Overview automatically generates a concise summary based on the current search results, providing key information from the results.
- **Improve Work Efficiency**: By avoiding the need to manually browse through numerous results, AI Overview helps users quickly focus on the most relevant information, saving time.
## Enabling AI Overview
{{% load-img "/img/core-features/ai_overview_02.png" "" %}}
To use the **AI Overview** feature, you need to configure it in the settings:
1. Open the **Settings** page and select the **Extensions** option.
2. In the **AI Overview Extension** configuration, choose an **AI assistant** that you want to use for summarization.
3. Configure the **trigger strategy**:
- **Minimum number of search results**: Set the minimum number of search results required to trigger AI Overview.
- **Minimum input length**: Set the minimum length of the input query; the summary function will only start when the input content is long enough.
- **Delay after typing stops**: Set the time delay after input stops to start the summary function, avoiding unnecessary summaries triggered by frequent input.
4. After saving the settings, in search mode, press `Meta + O` to enable the AI Overview feature, and AI Overview will automatically generate summaries for the search results according to your configuration.
> 💡 **Tip**: **The style and depth of the summary depend on the AI assistant you choose.**
>
> Think of it as an "information assistant"; the role you assign to it determines its reporting style:
>
> - **"Summary Abstract" assistant**: Provides quick, general summaries.
>
> - **"Technical Expert" assistant**: May generate summaries that focus more on technical specifications and code snippets.
>
> - **"Market Analyst" assistant**: Will pay more attention to market data, competitive dynamics, etc.
>
>
> 💡 **Tip**: **For faster response speed**
>
> If you pursue **ultimate response speed**, it is recommended to configure an assistant using a **fast token-generation, non-inference type model** for the AI Overview feature. Such models can quickly generate summaries for you, making information acquisition smooth.

View File

@@ -0,0 +1,41 @@
---
weight: 4
title: Application Search
---
# Application Search
The **Applications** feature allows you to directly search for and launch locally installed applications in Coco AI. You can quickly find and open any application through the unified search entry without switching windows or manually searching.
{{% load-img "/img/core-features/application_search_01.png" "" %}}
## Feature Overview
- **Quick Launch**: Enter the application name in the search box to instantly match results and quickly open the program.
- **Custom Search Scope**: Control which directories' applications are indexed and displayed through settings.
## Feature Settings
{{% load-img "/img/core-features/application_search_02.png" "" %}}
To use the **AI Overview** feature, you need to configure it in the settings:
1. **Search Scope**
Specify the paths where Coco AI will search for executable applications.
- For example:
- macOS: `/Applications`, `~/Applications`
- Windows: `C:\Program Files`, `C:\Users\<User>\AppData\Local`
- You can add or remove paths according to actual needs to avoid displaying irrelevant programs.
2. **Rebuild Index**
Rescan and update the local application index.
- Usually, there is no need to perform this manually.
- If you find that an installed application does not appear in the search results, you can click **Rebuild Index** to manually retry and update the results.

View File

@@ -0,0 +1,32 @@
---
weight: 5
title: Calculator
---
# Calculator
Coco AI provides a concise calculator function that allows users to perform quickquick basic mathematical calculations directly in the input box without opening a separate calculator application. Simply enter an arithmetic expression, and the system will instantly provide the result. It also supports copying the arithmetic expression and the calculation result for easy use at any time.
{{% load-img "/img/core-features/calculator_01.png" "" %}}
## Feature Overview
- **Quick Calculation**: Enter basic mathematical expressions in Coco AI's input box, and the system will automatically calculate and display the result.
- **Support for Basic Mathematical Operations**: Currently supports basic arithmetic operations such as addition, subtraction, multiplication, and division.
- **Copy Expression and Result**: Supports copying the complete arithmetic expression and calculation result for easy pasting into other applications.
## Usage Method
1. **Enter an Expression**:
- Directly input a basic mathematical expression in Coco AI's input box, for example: `256 * 42`
- The system will automatically calculate and display the result.
2. **Copy Expression and Result**:
- When the result is displayed, press `Enter` to copy the calculation result.
- Use the shortcut key `Meta + K` to open more operations, and select **Copy Answer**, **Copy Question and Answer**, or **Copy Answer (in Word)**
{{% load-img "/img/core-features/calculator_02.png" "" %}}

View File

@@ -0,0 +1,55 @@
---
weight: 3
title: File Search
---
# File Search
The File Search feature allows you to directly use the system's local search capability in Coco AI to quickly find files on your computer. You can flexibly set the search scope, excluded directories, file types, and search methods to get more accurate results.
{{% load-img "/img/core-features/filesearch_01.png" "" %}}
## Feature Overview
- **System-level search integration**: Coco AI leverages the file indexing capabilities provided by the operating system (such as macOS Spotlight, Windows Search, etc.) to achieve efficient local file search.
- **Flexible search control**: Supports custom search scopes and excluded paths, and can filter file types according to needs.
- **Content-level search**: On supported systems, you can choose to search file contents at the same time, not just file names.
## Feature Settings
{{% load-img "/img/core-features/filesearch_02.png" "" %}}
Coco AI is already equipped with local file search capabilities. You don't need any additional operations; you can start typing keywords in the search box to experience it immediately. If you want to exclude certain folders or add new search locations, you can manage your preferences at any time through **"Settings → Extensions → File Search"**.
1. **Search By**
Select the matching method for the search:
- **Name**: Only match file names (faster).
- **Name + Contents**: Match both file names and file contents (depending on operating system support).
2. **Search Scope**
Select the folders or disk locations to be included in the search.
- For example: `/Users/username/Documents` or `D:\Projects`
3. **Exclude Scope**
Specify paths that are not included in the search, used to reduce irrelevant results or improve search speed.
- For example: `node_modules`, `tmp`, `Library` and other system cache directories.
4. **Search File Types**
Limit the file extensions or types to be searched.
- For example: `.pdf`, `.docx`, `.md`, `.txt`
> 💡 **Tips**: **System Support Differences**
>
> - **macOS**: Implements mixed search of file names and contents through **Spotlight**, supporting fast response and fuzzy matching.
> - **Windows**: Relies on the system's **Windows Search Indexer**, supporting file name search; content search requires enabling content indexing for corresponding file types in system index settings.
> - **Linux**: Generally only supports file name search, depending on the distribution and configuration.

View File

@@ -0,0 +1,32 @@
---
weight: 2
title: Quick AI Access
---
# Quick AI Access
The **Quick AI Access** feature allows you to directly start a conversation with AI through the search box without switching to chat mode. This feature provides users with a smoother and more efficient interaction experience, especially suitable for scenarios where quick feedback or handling simple questions is needed.
{{% load-img "/img/core-features/quick_ai_access_01.png" "" %}}
## Feature Overview
{{% load-img "/img/core-features/quick_ai_access_02.png" "" %}}
- **Quickly Start a Conversation**: After entering content in the search box, press `Meta + Enter` to directly start a conversation with the AI assistant without switching to chat mode.
- **Instant Response**: Coco AI will display the conversation reply in the same window, providing answers or suggestions quickly.
- **Switch from Conversation Mode**: After completing a quick conversation, press `Meta + Enter` to switch to the full chat mode and continue multi-turn conversations.
## Enabling Quick AI Access
{{% load-img "/img/core-features/quick_ai_access_03.png" "" %}}
To use the **Quick AI Access** feature, you need to configure it in the settings:
1. Open the **Settings** page and select the **Extensions** option.
2. In the **Quick AI Access Extension** configuration, associate an AI assistant that you want to quickly access via `Meta + Enter`.
3. After saving the settings, you can directly start a conversation with the selected assistant through `Meta + Enter` in the search box.

View File

@@ -0,0 +1,45 @@
---
weight: 6
title: Window Management
---
# Window Management
Easily adjust, reorganize, and move the windows you're focusing on.
No need for manual dragging—quickly perform window layout operations through commands.
{{% load-img "/img/core-features/window_management_01.png" "" %}}
## Feature Overview
- **Move Windows**: Move the current window to the left half, right half, top half, or bottom half of the screen.
- **Resize Windows**: Quickly adjust to full screen, centered, 1/3, or 2/3 size layouts.
- **Multi-monitor Support**: Quickly move windows between multiple monitors.
- **Focus Windows**: Quickly focus on a specified window or application via shortcut keys.
## Usage
{{% load-img "/img/core-features/window_management_02.png" "" %}}
Enter commands included in Window Management in the **Coco AI search box** to browse and execute window management commands, such as:
- **Almost Maximize Bottom** — Maximize the window to the lower area of the screen
- **Bottom Half** — Move the current window to the lower half of the screen
- **Bottom Left Quarter** — Position the window to the bottom-left quarter
- **Bottom Right Sixth** — Place the window in the bottom-right sixth area
The window's position and size will be adjusted immediately after selecting a command.
> 💡 **Tips**
>
> - System-level window operations are supported; some special types of windows (such as full-screen or independent floating windows) may not be controllable.
> - It is recommended to combine custom shortcuts for commands to quickly achieve common window layouts.

View File

@@ -0,0 +1,5 @@
---
weight: 2
title: Core Features
bookCollapseSection: true
---

View File

@@ -0,0 +1,103 @@
---
weight: 4
title: AI Chat
---
# AI Chat
Coco AI is not just a search tool, but your AI intelligent center.
In chat mode, you can communicate with AI in natural language, ask questions, analyze files, and summarize knowledge.
{{% load-img "/img/core-features/basics_02.png" "" %}}
## Chat Entry
- Use the global shortcut (default: `Shift + Meta + Space`) to open the Coco AI interface.
- The interface is in chat mode (use the switch button or the shortcut `Meta + T` to switch modes).
- Enter natural language questions in the input box. Press `Enter` to start the conversation.
## Chat Interface and Functions
Coco AI's chat interface is designed to be concise and intuitive, allowing you to quickly switch AI assistants, access different Coco Servers, browse historical conversations, or use advanced capabilities such as deep thinking, web search, and tool calls.
{{% load-img "/img/core-features/ai_chat_01.png" "" %}}
#### Interface Overview
In chat mode, the Coco AI interface mainly consists of the following areas:
- **Top Bar**
- **Assistant Selection**: The drop-down menu in the upper left corner allows you to quickly switch between different AI assistants.
- **Historical Conversations**: Click the icon in the upper left corner to view recent conversations, and click any one to restore the conversation context.
- **Server Switching**: The cloud icon in the upper right corner shows the currently connected Coco Server, and you can switch or refresh the server with one click.
- **Independent Window Mode**: The icon in the upper right corner can pop up the current conversation into an independent window, facilitating multi-task collaboration or comparison viewing.
- **Middle Area**
- Displays conversation content and AI responses.
- **Bottom Input Area**
- Enter messages and press `Enter` to send, supporting voice input.
- The left function bar includes controls such as web search, tool call (MCP), and deep thinking switch.
#### Multiple Servers and Assistants
##### Switching Coco Server
Coco AI supports connecting to multiple Coco Servers, and each server can contain a different number of AI assistants.
Click the **server icon** in the upper right corner to view the current connection status:
- Displays the server name and online status.
- Lists the number of available AI assistants on the server.
- Supports one-click switching, refreshing, or entering the settings page.
{{% load-img "/img/core-features/ai_chat_03.png" "" %}}
##### Switching AI Assistants
The drop-down menu in the upper left corner lists all assistants in the current server.
Each assistant may have different capabilities and modes according to the configuration.
{{% load-img "/img/core-features/ai_chat_02.png" "" %}}
> 💡 **Tip**: When switching assistants in the same conversation, Coco AI will automatically retain the context of the current conversation. This means you can let different assistants take turns answering or supplementing analysis in the same round of conversation without re-entering background content.
#### Bottom Function Bar
The function buttons at the bottom left of the input box can quickly call the following capabilities:
| Function | Icon | Description |
| -------------------- | ---- | ------------------------------------------------------------ |
| **Deep Think Switch** | 🧠 | Turn on or off the deep think capability (only available for assistants in deep think mode). |
| **Search Switch** | 🌐 | Call the data sources connected in the Coco Server for real-time search. (Some data sources can be selected as needed) |
| **MCP Switch** | 🔨 | Call external tools or commands, such as database query, translation, task execution, etc. |
> **💡 Tip**: Search and MCP tool calls rely on the currently connected Coco Server, and their availability depends on server configuration.
{{% load-img "/img/core-features/ai_chat_04.png" "" %}}
#### Interactive Operations
- Press `Enter` to send a message
- Press `Shift + Enter` to wrap lines
- Press `Meta + U` to switch AI assistants
- Press `Meta + S` to switch Coco Server
- Press `Meta + E` to pop up the current conversation into an independent window

View File

@@ -0,0 +1,63 @@
---
weight: 5
title: Extension
---
# Extension
Extensions of Coco AI are plug-in modules that add specific functions to the core system. By installing extensions, you can greatly enhance the capabilities of Coco AI and create a personalized intelligent working environment.
{{% load-img "/img/core-features/extension_01.png" "" %}}
## How to Install Extensions
#### Install via Extension Store
In the Extension Store, you can browse or search for the required extensions. After finding the desired extension, press `↵` to view details, and click the install button on the details page. Coco AI will automatically complete the download and installation process.
{{% load-img "/img/core-features/extension_02.png" "" %}}
## How to Use Extensions
After installing an extension, you can call it through the unified search box.
#### Command-type Extensions (Commands)
In search mode, enter the command name or keywords, select the corresponding command from the search results, and press Enter to execute it.
#### View-type Extensions (Views)
View-type extensions provide a complete user interface, embedding visual applications in Coco AI, which can display complex information and offer rich interactive experiences.
In search mode, enter the extension name or keywords, select the corresponding extension from the search results, and press `↵` to enter the corresponding extension's interaction interface.
{{% load-img "/img/core-features/extension_04.png" "" %}}
## Extension Management
#### View Installed Extensions
Open Settings (shortcut key: `Meta+,`). On the Extensions page, you can:
- Filter by type
- Check the extension status (enabled/disabled)
- View and modify extension configurations
- Uninstall extensions
- Set extension command shortcuts or aliases
{{% load-img "/img/core-features/extension_03.png" "" %}}
#### Uninstall Extensions
On the Extensions page in Settings, select the extension you want to uninstall. On the right side of the extension title in the details section, click the `…` button and select Uninstall.

View File

@@ -0,0 +1,87 @@
---
weight: 6
title: Keyboard Shortcuts
---
# Keyboard Shortcuts
Coco AI provides an intuitive set of keyboard shortcuts to help you navigate efficiently, execute commands, switch modes, and manage conversations. Mastering these shortcuts can greatly enhance your user experience.
## You don't need to memorize the shortcuts
Simply go to **Settings** (shortcut: `Meta + ,`) → **General → Tooltip**, and turn on the shortcut hint switch. After enabling, when you hold down the modifier key, the corresponding shortcut hints will be displayed in real-time in each functional area of the interface.
{{% load-img "/img/core-features/shortcuts_01.png" "" %}}
{{% load-img "/img/core-features/shortcuts_03.png" "" %}}
## Global Shortcuts
These shortcuts work across any interface, helping you quickly access Coco AI's core functions:
- `Shift + Meta + Space` Open the Coco AI window
- `Meta + T` Switch between search/conversation modes
- `Meta + I` Return to the input box
- `Meta + P` Pin the window, keeping the Coco AI window displayed at the front of the desktop
- `Meta + ,` Open the Coco AI settings page
- `Esc` Close the Coco AI window
## Search Mode Shortcuts
In Coco AI's search mode, keyboard shortcuts can help you browse and filter search results more efficiently:
- `Enter` Open the selected result
- `Meta + Number` Select the result corresponding to the number and open it
- `Meta + K` View actionable items for the selected result
- `Tab` Use the data source of the current result as a filter condition
- `Arrow Up / Down` Select search results up and down
- `Meta + Arrow Up / Down` Quickly jump to the first result of the upper/lower category
## Chat Mode Shortcuts
In Coco AI's chat mode, keyboard shortcuts can help you quickly switch assistants, control conversations, and input information:
- `Enter` Send a message
- `Shift + Enter` Enter a new line
- `Meta + N` Create a new conversation
- `Meta + Y` View conversation history
- `Meta + U` Switch assistants
- `Meta + S` Switch Coco Server
- `Meta + E` Pop out the current conversation into an independent window
## Custom Shortcuts
Coco AI allows you to customize shortcuts in the settings to adjust according to your personal needs.
Simply go to **Settings** (shortcut: `Meta + ,`) → **Advanced → Keyboard Shortcuts** to modify the default shortcuts.
{{% load-img "/img/core-features/shortcuts_02.png" "" %}}

View File

@@ -0,0 +1,69 @@
---
weight: 3
title: Search
---
# Search
Coco AI's search function is designed to provide a unified, intelligent, and efficient cross-platform information retrieval experience. In search mode, you can quickly find local files, applications, commands, extensions, data sources in Coco Server (including Google Drive, Notion, Yuque, Hugo sites, RSS, Github, Postgres, etc.), and AI assistants through the search box.
{{% load-img "/img/core-features/search_02.png" "" %}}
## Search Entrance
- Open the Coco AI interface using the global shortcut (default: `Shift + Meta + Space`).
- The interface is in search mode (switch modes using the toggle button or the shortcut `Meta + T`).
- Enter keywords, file names, or natural language questions in the input box.
{{% load-img "/img/core-features/search_01.png" "" %}}
## Search Results
Coco AI will automatically return a set of concise, structured search results after you enter keywords.
#### Default Display Rules
- By default, the **first 10 results** are displayed.
- When results come from multiple data sources (such as Hugo sites, Google Drive, local files, etc.), they will be displayed **grouped by data source**.
- If there are fewer than 10 results, they will be displayed **without grouping** in a single list.
- Each result shows:
- **Title** (file name, note name, or conversation title)
- **Directory information** (belonging path or location)
- Key matching fragments or summaries
> 💡 **Tip**: Grouping allows you to quickly understand the range of matching content in different data sources, saving time in filtering.
#### Quick Filtering and Navigation
{{% load-img "/img/core-features/search_03.png" "" %}}
When browsing results, you can use the **Tab key** to quickly filter the currently selected result:
- After pressing `Tab`, Coco AI will automatically use the data source of the current result as the filtering condition.
- The interface will switch to the separate search view of that data source.
In the single data source search view, Coco AI will display more abundant content, including:
- **More search results** (no longer limited to 10)
- **More file attributes** (size, type, modification time, etc.)
- **Content thumbnails** or **preview summaries** to facilitate quick judgment of relevance
When the selected result is an AI assistant or extension, the Tab key can initiate a quick conversation with the AI assistant or open the extension.
#### Interactive Operations
- Use the `↓↑` arrow keys or mouse to select result items.
- Press `Enter` or `Meta + number` to open the result item.
- Press `Tab` to filter to the results of that data source or further interact with the selected result.
- Press the `Backspace` key to delete input content and return to the previous level.
- Press `Esc` to exit the Coco AI interface.

View File

@@ -0,0 +1,48 @@
---
weight: 7
title: Settings
---
# Settings
In Coco AI, you can adjust various settings of the application according to your personal needs (shortcut: `Meta + ,`) . The settings page is divided into several main sections, allowing you to easily manage startup items, shortcuts, extensions, connections, and advanced features.
## General
In the General Settings section, you can adjust Coco AI's startup items, startup shortcuts, interface appearance, and language.
{{% load-img "/img/core-features/settings_01.png" "" %}}
## Extension
In the Extension Settings, you can view, manage, and configure installed extensions.
{{% load-img "/img/core-features/settings_02.png" "" %}}
## Connect
In the Connect Settings, you can view and manage connections to Coco Server. This section involves logging in, enabling/disabling, and deleting connected servers.
{{% load-img "/img/core-features/settings_03.png" "" %}}
## Advanced
In the Advanced Settings, you can configure more detailed options such as startup, connections, shortcuts, and version updates.
{{% load-img "/img/core-features/settings_04.png" "" %}}
## About
In the About Us section, you can view current version information, access help documentation, and submit feedback.
{{% load-img "/img/core-features/settings_05.png" "" %}}

View File

@@ -0,0 +1,33 @@
---
weight: 2
title: The Basics
---
# The Basics
Coco AI is always ready to help you quickly go from "wanting to ask" to "finding answers". This page will briefly introduce the core concepts and quick start process of Coco AI.
## Core Operations
- Use the global shortcut (default: `Shift + Meta + Space`) to open the Coco AI interface.
- In the interface, use the toggle button or shortcut key (default: `Meta + T`) to switch between search and AI chat modes.
- In search mode, enter keywords in the input box to search for local files, cloud data sources, applications, commands, etc., then press `Enter` to open them.
- In chat mode, select different AI assistants and talk directly to them.
- Enhance functions with extensions: such as multimedia control, screenshot, window management, etc.
## Quick Start
The following operations can help you quickly get familiar with Coco AI:
1. Press the shortcut key `Shift + Meta + Space` to open Coco AI.
2. In search mode, enter a keyword (e.g., "project report") in the input box to see Coco AI search for related files.
3. In search mode, enter a mathematical operation (e.g., `256*42`) in the input box to view the quick calculation result.
4. In search mode, select a search result and press the hotkey `tab` to activate the data source or category filter.
5. In search mode, search for and open the Extensions Store, then install an extension.
6. In chat mode, enter a question (e.g., "What is Coco AI") in the input box to see the answer from the AI assistant.
7. In chat mode, click the icon in the upper right corner of the window (shortcut key: `Meta + E`) to activate the independent window chat.
8. In the settings (shortcut key: `Meta + ,`), go to the connection settings to connect to Coco Cloud or your self-deployed Coco Server, so as to access cloud data sources and AI assistants, allowing Coco AI to achieve one-stop search.

View File

@@ -1,5 +1,5 @@
---
weight: 10
weight: 1
title: "Getting Started"
bookCollapseSection: false
---

View File

@@ -1,5 +1,5 @@
---
weight: 10
weight: 1
title: "Installation"
bookCollapseSection: true
---

View File

@@ -7,12 +7,22 @@ title: "Release Notes"
Information about release notes of Coco App is provided here.
## Latest (In development)
## Latest (In development)
### ❌ Breaking changes
### 🚀 Features
### 🐛 Bug fix
### ✈️ Improvements
## 0.9.1 (2025-12-05)
### ❌ Breaking changes
### 🚀 Features
- feat: add selection toolbar window for mac #980
- feat: add a heartbeat worker to check Coco server availability #988
- feat: selection settings add & delete #992
### 🐛 Bug fix
- fix: search_extension should not panic when ext is not found #983
@@ -20,6 +30,10 @@ Information about release notes of Coco App is provided here.
### ✈️ Improvements
- chore: write panic message to stdout in panic hook #989
- refactor: error handling in install_extension interfaces #995
- chore: adjust the position of the compact mode window #997
## 0.9.0 (2025-11-19)
### ❌ Breaking changes

Binary file not shown.

After

Width:  |  Height:  |  Size: 802 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 846 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 785 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 806 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 836 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 657 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 851 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 674 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 777 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 845 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 854 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 849 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 840 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 650 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 694 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 878 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 671 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 896 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 826 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 654 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 849 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 871 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 876 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 611 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 637 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 631 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 586 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 620 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 613 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 832 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 874 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 646 KiB

View File

@@ -1,7 +1,7 @@
{
"name": "coco",
"private": true,
"version": "0.9.0",
"version": "0.9.1",
"type": "module",
"scripts": {
"dev": "vite",
@@ -19,9 +19,12 @@
},
"dependencies": {
"@headlessui/react": "^2.2.2",
"@infinilabs/custom-icons": "0.0.4",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.3",
"@tauri-apps/api": "^2.5.0",
"@tauri-apps/plugin-autostart": "~2.2.0",
"@tauri-apps/plugin-clipboard-manager": "~2.3.2",
"@tauri-apps/plugin-deep-link": "^2.2.1",
"@tauri-apps/plugin-dialog": "^2.2.1",
"@tauri-apps/plugin-global-shortcut": "~2.0.0",
@@ -33,6 +36,7 @@
"@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",
"@tauri-store/zustand": "^1.1.0",
"@wavesurfer/react": "^1.0.11",
"ahooks": "^3.8.4",
"axios": "^1.12.0",
@@ -96,4 +100,4 @@
"vite": "^5.4.19"
},
"packageManager": "pnpm@10.11.0+sha512.6540583f41cc5f628eb3d9773ecee802f4f9ef9923cc45b69890fb47991d4b092964694ec3a4f738a420c918a333062c8b925d312f42e4f0c263eb603551f977"
}
}

119
pnpm-lock.yaml generated
View File

@@ -11,6 +11,12 @@ importers:
'@headlessui/react':
specifier: ^2.2.2
version: 2.2.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@infinilabs/custom-icons':
specifier: 0.0.4
version: 0.0.4(lucide-react@0.461.0(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-separator':
specifier: ^1.1.8
version: 1.1.8(@types/react-dom@18.3.7(@types/react@18.3.26))(@types/react@18.3.26)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-slot':
specifier: ^1.2.3
version: 1.2.3(@types/react@18.3.26)(react@18.3.1)
@@ -20,6 +26,9 @@ importers:
'@tauri-apps/plugin-autostart':
specifier: ~2.2.0
version: 2.2.0
'@tauri-apps/plugin-clipboard-manager':
specifier: ~2.3.2
version: 2.3.2
'@tauri-apps/plugin-deep-link':
specifier: ^2.2.1
version: 2.4.5
@@ -53,6 +62,9 @@ importers:
'@tauri-apps/plugin-window':
specifier: 2.0.0-alpha.1
version: 2.0.0-alpha.1
'@tauri-store/zustand':
specifier: ^1.1.0
version: 1.1.0(@types/react@18.3.26)(immer@10.2.0)(react@18.3.1)(use-sync-external-store@1.5.0(react@18.3.1))
'@wavesurfer/react':
specifier: ^1.0.11
version: 1.0.11(react@18.3.1)(wavesurfer.js@7.11.1)
@@ -691,6 +703,13 @@ packages:
'@iconify/utils@3.0.2':
resolution: {integrity: sha512-EfJS0rLfVuRuJRn4psJHtK2A9TqVnkxPpHY6lYHiB9+8eSuudsxbwMiavocG45ujOo6FJ+CIRlRnlOGinzkaGQ==}
'@infinilabs/custom-icons@0.0.4':
resolution: {integrity: sha512-Oz5i06qW5a3wMfbJIZ5LESyO4V9p8njdoKnb/2Mf98ttRjTIedTXqrIWxLz8Tz84OTLi7mKswEQ71lAGeSqlug==}
peerDependencies:
lucide-react: '>=0.454.0'
react: '>=18'
react-dom: '>=18'
'@inquirer/checkbox@4.1.5':
resolution: {integrity: sha512-swPczVU+at65xa5uPfNP9u3qx/alNwiaykiI/ExpsmMSQW55trmZcwhYWzw/7fj+n6Q8z1eENvR7vFfq9oPSAQ==}
engines: {node: '>=18'}
@@ -1029,6 +1048,32 @@ packages:
'@types/react':
optional: true
'@radix-ui/react-primitive@2.1.4':
resolution: {integrity: sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-separator@1.1.8':
resolution: {integrity: sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-slot@1.2.3':
resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==}
peerDependencies:
@@ -1038,6 +1083,15 @@ packages:
'@types/react':
optional: true
'@radix-ui/react-slot@1.2.4':
resolution: {integrity: sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@react-aria/focus@3.20.2':
resolution: {integrity: sha512-Q3rouk/rzoF/3TuH6FzoAIKrl+kzZi9LHmr8S5EqLAOyP9TXIKG34x2j42dZsAhrw7TbF9gA8tBKwnCNH4ZV+Q==}
peerDependencies:
@@ -1286,6 +1340,9 @@ packages:
'@tauri-apps/plugin-autostart@2.2.0':
resolution: {integrity: sha512-TzVcDZdOvdot0avkpstUWJKKEl4cyxLpFB9DZZRW5zH8k+Bv8IVJmO0zyYuw+7oKlGdHOINbD/7Je7GHMViw5w==}
'@tauri-apps/plugin-clipboard-manager@2.3.2':
resolution: {integrity: sha512-CUlb5Hqi2oZbcZf4VUyUH53XWPPdtpw43EUpCza5HWZJwxEoDowFzNUDt1tRUXA8Uq+XPn17Ysfptip33sG4eQ==}
'@tauri-apps/plugin-deep-link@2.4.5':
resolution: {integrity: sha512-Zf2RTj1D9IQQ45/jqW8XTKvql24HqlPjcpv0mV/O2jHQkNe11HOTZBVj6IK37qs+MWV7xZzcmazx/QVZnhAwaQ==}
@@ -1320,6 +1377,12 @@ packages:
'@tauri-apps/plugin-window@2.0.0-alpha.1':
resolution: {integrity: sha512-dFOAgal/3Txz3SQ+LNQq0AK1EPC+acdaFlwPVB/6KXUZYmaFleIlzgxDVoJCQ+/xOhxvYrdQaFLefh0I/Kldbg==}
'@tauri-store/shared@0.10.2':
resolution: {integrity: sha512-hnEBbe/m9UG5ATSBp4yJOosLkX5QLblVsGIvz7mtBAqmzeWFfjP4+8X/T81L+bCH6+ref/0vsR9Ekex138kAfg==}
'@tauri-store/zustand@1.1.0':
resolution: {integrity: sha512-893TmbuiS+uX1KUNzALxnY9FRU9CUpOfZwl8tf7Wxoq4UU2ZabtZqWzLRVBGbs9rejuHGFMJeRE5U/U6tfzz7g==}
'@tootallnate/quickjs-emscripten@0.23.0':
resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==}
@@ -2117,6 +2180,9 @@ packages:
resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
engines: {node: '>= 0.4'}
es-toolkit@1.42.0:
resolution: {integrity: sha512-SLHIyY7VfDJBM8clz4+T2oquwTQxEzu263AyhVK4jREOAwJ+8eebaa4wM3nlvnAqhDrMm2EsA6hWHaQsMPQ1nA==}
esbuild@0.21.5:
resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==}
engines: {node: '>=12'}
@@ -4222,6 +4288,12 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@infinilabs/custom-icons@0.0.4(lucide-react@0.461.0(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
lucide-react: 0.461.0(react@18.3.1)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
'@inquirer/checkbox@4.1.5(@types/node@22.18.12)':
dependencies:
'@inquirer/core': 10.1.10(@types/node@22.18.12)
@@ -4553,6 +4625,24 @@ snapshots:
optionalDependencies:
'@types/react': 18.3.26
'@radix-ui/react-primitive@2.1.4(@types/react-dom@18.3.7(@types/react@18.3.26))(@types/react@18.3.26)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/react-slot': 1.2.4(@types/react@18.3.26)(react@18.3.1)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
optionalDependencies:
'@types/react': 18.3.26
'@types/react-dom': 18.3.7(@types/react@18.3.26)
'@radix-ui/react-separator@1.1.8(@types/react-dom@18.3.7(@types/react@18.3.26))(@types/react@18.3.26)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/react-primitive': 2.1.4(@types/react-dom@18.3.7(@types/react@18.3.26))(@types/react@18.3.26)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
optionalDependencies:
'@types/react': 18.3.26
'@types/react-dom': 18.3.7(@types/react@18.3.26)
'@radix-ui/react-slot@1.2.3(@types/react@18.3.26)(react@18.3.1)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.26)(react@18.3.1)
@@ -4560,6 +4650,13 @@ snapshots:
optionalDependencies:
'@types/react': 18.3.26
'@radix-ui/react-slot@1.2.4(@types/react@18.3.26)(react@18.3.1)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.26)(react@18.3.1)
react: 18.3.1
optionalDependencies:
'@types/react': 18.3.26
'@react-aria/focus@3.20.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@react-aria/interactions': 3.25.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -4746,6 +4843,10 @@ snapshots:
dependencies:
'@tauri-apps/api': 2.9.0
'@tauri-apps/plugin-clipboard-manager@2.3.2':
dependencies:
'@tauri-apps/api': 2.9.0
'@tauri-apps/plugin-deep-link@2.4.5':
dependencies:
'@tauri-apps/api': 2.9.0
@@ -4790,6 +4891,22 @@ snapshots:
dependencies:
'@tauri-apps/api': 2.0.0-alpha.6
'@tauri-store/shared@0.10.2':
dependencies:
'@tauri-apps/api': 2.9.0
es-toolkit: 1.42.0
'@tauri-store/zustand@1.1.0(@types/react@18.3.26)(immer@10.2.0)(react@18.3.1)(use-sync-external-store@1.5.0(react@18.3.1))':
dependencies:
'@tauri-apps/api': 2.9.0
'@tauri-store/shared': 0.10.2
zustand: 5.0.8(@types/react@18.3.26)(immer@10.2.0)(react@18.3.1)(use-sync-external-store@1.5.0(react@18.3.1))
transitivePeerDependencies:
- '@types/react'
- immer
- react
- use-sync-external-store
'@tootallnate/quickjs-emscripten@0.23.0': {}
'@types/babel__core@7.20.5':
@@ -5610,6 +5727,8 @@ snapshots:
has-tostringtag: 1.0.2
hasown: 2.0.2
es-toolkit@1.42.0: {}
esbuild@0.21.5:
optionalDependencies:
'@esbuild/aix-ppc64': 0.21.5

1943
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package]
name = "coco"
version = "0.9.0"
version = "0.9.1"
description = "Search, connect, collaborate all in one place."
authors = ["INFINI Labs"]
edition = "2024"
@@ -119,6 +119,9 @@ toml = "0.8"
path-clean = "1.0.1"
actix-files = "0.6.8"
actix-web = "4.11.0"
tauri-plugin-clipboard-manager = "2"
tauri-plugin-zustand = "1"
snafu = "0.8.9"
[dev-dependencies]
tempfile = "3.23.0"
@@ -130,6 +133,8 @@ objc2 = "0.6.2"
objc2-core-foundation = {version = "0.3.1", features = ["CFString", "CFCGTypes", "CFArray"] }
objc2-application-services = { version = "0.3.1", features = ["HIServices"] }
objc2-core-graphics = { version = "=0.3.1", features = ["CGEvent"] }
# macOS-only: used by selection_monitor.rs to check AX trust/prompt
macos-accessibility-client = "0.0.1"
[target."cfg(target_os = \"linux\")".dependencies]
gio = "0.21.2"

View File

@@ -38,5 +38,9 @@
<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>
<key>NSAppleEventsUsageDescription</key>
<string>Coco AI requires access to Apple Events to enable certain features, such as opening files and applications.</string>
<key>NSAccessibility</key>
<true/>
</dict>
</plist>

View File

@@ -2,7 +2,7 @@
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main", "chat", "settings", "check"],
"windows": ["main", "chat", "settings", "check", "selection"],
"permissions": [
"core:default",
"core:event:allow-emit",
@@ -30,6 +30,7 @@
"core:window:allow-set-always-on-top",
"core:window:deny-internal-toggle-maximize",
"core:window:allow-set-shadow",
"core:window:allow-set-position",
"core:app:allow-set-app-theme",
"shell:default",
"http:default",

View File

@@ -0,0 +1,5 @@
{
"identifier": "zustand",
"windows": ["*"],
"permissions": ["zustand:default", "core:event:default"]
}

View File

@@ -1,13 +1,14 @@
use crate::common::assistant::ChatRequestMessage;
use crate::common::http::convert_query_params_to_strings;
use crate::common::register::SearchSourceRegistry;
use crate::server::http_client::HttpClient;
use crate::server::http_client::{DecodeResponseSnafu, HttpClient, HttpRequestError};
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 snafu::ResultExt;
use std::collections::HashMap;
use tauri::{AppHandle, Emitter, Manager};
use tokio::io::AsyncBufReadExt;
@@ -19,7 +20,7 @@ pub async fn chat_history(
from: u32,
size: u32,
query: Option<String>,
) -> Result<String, String> {
) -> Result<String, HttpRequestError> {
let mut query_params = Vec::new();
// Add from/size as number values
@@ -32,12 +33,7 @@ pub async fn chat_history(
}
}
let response = HttpClient::get(&server_id, "/chat/_history", Some(query_params))
.await
.map_err(|e| {
dbg!("Error get history: {}", &e);
format!("Error get history: {}", e)
})?;
let response = HttpClient::get(&server_id, "/chat/_history", Some(query_params)).await?;
common::http::get_response_body_text(response).await
}
@@ -49,7 +45,7 @@ pub async fn session_chat_history(
session_id: String,
from: u32,
size: u32,
) -> Result<String, String> {
) -> Result<String, HttpRequestError> {
let mut query_params = Vec::new();
// Add from/size as number values
@@ -58,9 +54,7 @@ pub async fn session_chat_history(
let path = format!("/chat/{}/_history", session_id);
let response = HttpClient::get(&server_id, path.as_str(), Some(query_params))
.await
.map_err(|e| format!("Error get session message: {}", e))?;
let response = HttpClient::get(&server_id, path.as_str(), Some(query_params)).await?;
common::http::get_response_body_text(response).await
}
@@ -70,12 +64,10 @@ pub async fn open_session_chat(
_app_handle: AppHandle,
server_id: String,
session_id: String,
) -> Result<String, String> {
) -> Result<String, HttpRequestError> {
let path = format!("/chat/{}/_open", session_id);
let response = HttpClient::post(&server_id, path.as_str(), None, None)
.await
.map_err(|e| format!("Error open session: {}", e))?;
let response = HttpClient::post(&server_id, path.as_str(), None, None).await?;
common::http::get_response_body_text(response).await
}
@@ -85,12 +77,10 @@ pub async fn close_session_chat(
_app_handle: AppHandle,
server_id: String,
session_id: String,
) -> Result<String, String> {
) -> Result<String, HttpRequestError> {
let path = format!("/chat/{}/_close", session_id);
let response = HttpClient::post(&server_id, path.as_str(), None, None)
.await
.map_err(|e| format!("Error close session: {}", e))?;
let response = HttpClient::post(&server_id, path.as_str(), None, None).await?;
common::http::get_response_body_text(response).await
}
@@ -100,13 +90,11 @@ pub async fn cancel_session_chat(
server_id: String,
session_id: String,
query_params: Option<HashMap<String, Value>>,
) -> Result<String, String> {
) -> Result<String, HttpRequestError> {
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(), query_params, None)
.await
.map_err(|e| format!("Error cancel session: {}", e))?;
let response = HttpClient::post(&server_id, path.as_str(), query_params, None).await?;
common::http::get_response_body_text(response).await
}
@@ -270,14 +258,23 @@ pub async fn chat_chat(
}
#[tauri::command]
pub async fn delete_session_chat(server_id: String, session_id: String) -> Result<bool, String> {
pub async fn delete_session_chat(
server_id: String,
session_id: String,
) -> Result<bool, HttpRequestError> {
let response =
HttpClient::delete(&server_id, &format!("/chat/{}", session_id), None, None).await?;
if response.status().is_success() {
let status = response.status();
if status.is_success() {
Ok(true)
} else {
Err(format!("Delete failed with status: {}", response.status()))
Err(HttpRequestError::RequestFailed {
status: status.as_u16(),
error_response_body_str: None,
coco_server_api_error_response_body: None,
})
}
}
@@ -287,7 +284,7 @@ pub async fn update_session_chat(
session_id: String,
title: Option<String>,
context: Option<HashMap<String, Value>>,
) -> Result<bool, String> {
) -> Result<bool, HttpRequestError> {
let mut body = HashMap::new();
if let Some(title) = title {
body.insert("title".to_string(), Value::String(title));
@@ -306,8 +303,7 @@ pub async fn update_session_chat(
None,
Some(reqwest::Body::from(serde_json::to_string(&body).unwrap())),
)
.await
.map_err(|e| format!("Error updating session: {}", e))?;
.await?;
Ok(response.status().is_success())
}
@@ -317,15 +313,10 @@ 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))?;
) -> Result<Value, HttpRequestError> {
let response = HttpClient::post(&server_id, "/assistant/_search", query_params, None).await?;
response
.json::<Value>()
.await
.map_err(|err| err.to_string())
response.json::<Value>().await.context(DecodeResponseSnafu)
}
#[tauri::command]
@@ -333,19 +324,15 @@ pub async fn assistant_get(
_app_handle: AppHandle,
server_id: String,
assistant_id: String,
) -> Result<Value, String> {
) -> Result<Value, HttpRequestError> {
let response = HttpClient::get(
&server_id,
&format!("/assistant/{}", assistant_id),
None, // headers
)
.await
.map_err(|e| format!("Error getting assistant: {}", e))?;
.await?;
response
.json::<Value>()
.await
.map_err(|err| err.to_string())
response.json::<Value>().await.context(DecodeResponseSnafu)
}
/// Gets the information of the assistant specified by `assistant_id` by querying **all**
@@ -356,7 +343,7 @@ pub async fn assistant_get(
pub async fn assistant_get_multi(
app_handle: AppHandle,
assistant_id: String,
) -> Result<Value, String> {
) -> Result<Option<Value>, HttpRequestError> {
let search_sources = app_handle.state::<SearchSourceRegistry>();
let sources_future = search_sources.get_sources();
let sources_list = sources_future.await;
@@ -375,19 +362,17 @@ pub async fn assistant_get_multi(
let path = format!("/assistant/{}", assistant_id);
let fut = async move {
let res_response = HttpClient::get(
let 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),
}
.await?;
response
.json::<serde_json::Value>()
.await
.context(DecodeResponseSnafu)
};
futures.push(fut);
@@ -419,15 +404,12 @@ pub async fn assistant_get_multi(
// ```
if let Some(found) = response_json.get("found") {
if found == true {
return Ok(response_json);
return Ok(Some(response_json));
}
}
}
Err(format!(
"could not find Assistant [{}] on all the Coco servers",
assistant_id
))
Ok(None)
}
use regex::Regex;
@@ -453,7 +435,7 @@ pub async fn ask_ai(
server_id: String,
assistant_id: String,
client_id: String,
) -> Result<(), String> {
) -> Result<(), HttpRequestError> {
let cleaned = remove_icon_fields(message.as_str());
let body = serde_json::json!({ "message": cleaned });
@@ -472,13 +454,19 @@ pub async fn ask_ai(
)
.await?;
if response.status() == 429 {
let status = response.status().as_u16();
if 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()));
return Err(HttpRequestError::RequestFailed {
status,
error_response_body_str: None,
coco_server_api_error_response_body: None,
});
}
let stream = response.bytes_stream();
@@ -491,7 +479,7 @@ pub async fn ask_ai(
dbg!("Received line: {}", &line);
let _ = app_handle.emit(&client_id, line).map_err(|err| {
println!("Failed to emit: {:?}", err);
log::error!("Failed to emit: {:?}", err);
});
}

View File

@@ -1,9 +0,0 @@
// use std::collections::HashMap;
use serde::{Deserialize, Serialize};
// use crate::common::health::Status;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RequestAccessTokenResponse {
pub access_token: String,
pub expire_in: u32,
}

View File

@@ -1,81 +1,162 @@
use reqwest::StatusCode;
use serde::{Deserialize, Serialize, Serializer};
use thiserror::Error;
use snafu::prelude::*;
fn serialize_optional_status_code<S>(
status_code: &Option<StatusCode>,
use crate::server::http_client::HttpRequestError;
#[derive(Debug, Deserialize, Serialize)]
pub struct ApiErrorCause {
/// Only the top-level error contains this.
#[serde(default)]
pub root_cause: Option<Vec<ApiErrorCause>>,
#[serde(default)]
pub r#type: Option<String>,
#[serde(default)]
pub reason: Option<String>,
/// Recursion, [error A] cause by [error B] caused by [error C]
#[serde(default)]
pub caused_by: Option<Box<ApiErrorCause>>,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct ApiError {
#[serde(default)]
pub error: Option<ApiErrorCause>,
#[serde(default)]
pub status: Option<u16>,
}
#[derive(Debug, Snafu, Serialize)]
#[snafu(visibility(pub(crate)))]
pub enum SearchError {
#[snafu(display("HTTP request error"))]
HttpError { source: HttpRequestError },
#[snafu(display("failed to decode query response"))]
ResponseDecodeError {
#[serde(serialize_with = "serialize_error")]
source: serde_json::Error,
},
/// The search operation timed out.
#[snafu(display("search operation timed out"))]
SearchTimeout,
#[snafu(display("an internal error occurred: '{}'", error))]
InternalError { error: String },
}
pub(crate) fn serialize_error<S, E: std::error::Error>(
error: &E,
serializer: S,
) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
match status_code {
Some(code) => serializer.serialize_str(&format!("{:?}", code)),
None => serializer.serialize_none(),
}
serializer.serialize_str(&report_error(error, ReportErrorStyle::SingleLine))
}
#[allow(unused)]
#[derive(Debug, Deserialize)]
pub struct ErrorCause {
#[serde(default)]
pub r#type: Option<String>,
#[serde(default)]
pub reason: Option<String>,
/// `ReportErrorStyle` controls the error reporting format.
pub(crate) enum ReportErrorStyle {
/// Report it in one line of message. This is suitable when you write dump
/// errors to logs.
///
/// ```text
/// 'failed to installed extension', caused by ['Json parsing error' 'I/O error: file not found']
/// ```
SingleLine,
/// Allow it to span multiple lines.
///
/// ```text
/// failed to installed extension
/// Caused by:
///
/// 0: Json parsing error
/// 1: I/O error: file not found
/// ```
MultipleLines,
}
#[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>,
}
/// In Rust, a typical Display impl of an Error won't contain it source information[1],
/// so we need a reporter to report the full error message.
///
/// [1]: https://stackoverflow.com/q/62869360/14092446
pub(crate) fn report_error<E: std::error::Error>(e: &E, style: ReportErrorStyle) -> String {
use std::fmt::Write;
#[derive(Debug, Deserialize)]
pub struct ErrorResponse {
#[serde(default)]
pub error: Option<ErrorDetail>,
#[serde(default)]
#[allow(unused)]
pub status: Option<u16>,
}
match style {
ReportErrorStyle::SingleLine => {
let mut error_msg = format!("'{}'", e);
if let Some(cause) = e.source() {
error_msg.push_str(", caused by: [");
#[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(),
for (i, e) in std::iter::successors(Some(cause), |e| e.source()).enumerate() {
if i != 0 {
error_msg.push(' ');
}
write!(&mut error_msg, "'{}'", e).expect("failed to write in-memory string");
}
error_msg.push(']');
}
error_msg
}
ReportErrorStyle::MultipleLines => snafu::Report::from_error(e).to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io;
#[derive(Debug, Snafu)]
enum Error {
#[snafu(display("I/O Error"))]
Io { source: io::Error },
#[snafu(display("Foo"))]
Foo,
#[snafu(display("Nested"))]
Nested { source: ReadError },
}
#[derive(Debug, Snafu)]
enum ReadError {
#[snafu(display("failed to read config file"))]
ReadConfig { source: io::Error },
}
#[test]
fn test_report_error_single_line_one_caused_by() {
let err = Error::Io {
source: io::Error::new(io::ErrorKind::NotFound, "file Cargo.toml not found"),
};
let error_msg = report_error(&err, ReportErrorStyle::SingleLine);
assert_eq!(
error_msg,
"'I/O Error', caused by: ['file Cargo.toml not found']"
);
}
#[test]
fn test_report_error_single_line_multiple_caused_by() {
let err = Error::Nested {
source: ReadError::ReadConfig {
source: io::Error::new(io::ErrorKind::NotFound, "not found"),
},
};
let error_msg = report_error(&err, ReportErrorStyle::SingleLine);
assert_eq!(
error_msg,
"'Nested', caused by: ['failed to read config file' 'not found']"
);
}
#[test]
fn test_report_error_single_line_no_caused_by() {
let err = Error::Foo;
let error_msg = report_error(&err, ReportErrorStyle::SingleLine);
assert_eq!(error_msg, "'Foo'");
}
}

View File

@@ -1,7 +1,11 @@
use crate::common;
use crate::{
common,
server::http_client::{DecodeResponseSnafu, HttpRequestError},
};
use reqwest::Response;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use snafu::ResultExt;
use std::collections::HashMap;
use tauri_plugin_store::JsonValue;
@@ -21,36 +25,35 @@ pub struct Source {
pub status: String,
}
pub async fn get_response_body_text(response: Response) -> Result<String, String> {
pub async fn get_response_body_text(response: Response) -> Result<String, HttpRequestError> {
let status = response.status().as_u16();
let body = response
.text()
.await
.map_err(|e| format!("Failed to read response body: {}, code: {}", e, status))?;
.context(DecodeResponseSnafu)?
.trim()
.to_string();
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);
if body.is_empty() {
return Err(HttpRequestError::RequestFailed {
status,
error_response_body_str: None,
coco_server_api_error_response_body: None,
});
}
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)
}
}
// Ignore this error, including a `serde_json::Error` in `HttpRequestError::RequestFailed`
// would be too verbose. And it is still easy to debug without this error, since we have
// the raw error response body.
let api_error = serde_json::from_str::<common::error::ApiError>(&body).ok();
Err(HttpRequestError::RequestFailed {
status,
error_response_body_str: Some(body),
coco_server_api_error_response_body: api_error,
})
} else {
Ok(body)
}

View File

@@ -1,5 +1,4 @@
pub mod assistant;
pub mod auth;
pub mod connector;
pub mod datasource;
pub mod document;

View File

@@ -22,9 +22,11 @@ impl SearchSourceRegistry {
sources.clear();
}
pub async fn remove_source(&self, id: &str) {
/// Remove the SearchSource specified by `id`, return a boolean indicating
/// if it get removed or not.
pub async fn remove_source(&self, id: &str) -> bool {
let mut sources = self.sources.write().await;
sources.remove(id);
sources.remove(id).is_some()
}
#[allow(dead_code)]

View File

@@ -678,7 +678,7 @@ impl SearchSource for ApplicationSearchSource {
.expect("tx dropped, the runtime thread is possibly dead")
.map_err(|pizza_engine_err| {
let err_str = pizza_engine_err.to_string();
SearchError::InternalError(err_str)
SearchError::InternalError { error: err_str }
})?;
let total_hits = search_result.total_hits;

View File

@@ -85,7 +85,7 @@ impl SearchSource for FileSearchExtensionSearchSource {
let hits = implementation::hits(&query_string, from, size, &config)
.await
.map_err(SearchError::InternalError)?;
.map_err(|e| SearchError::InternalError { error: e })?;
let total_hits = hits.len();
Ok(QueryResponse {

View File

@@ -11,6 +11,7 @@ pub mod window_management;
use super::Extension;
use crate::SearchSourceRegistry;
use crate::common::error::{ReportErrorStyle, report_error};
use crate::extension::built_in::application::{set_apps_hotkey, unset_apps_hotkey};
use crate::extension::{
ExtensionBundleIdBorrowed, PLUGIN_JSON_FILE_NAME, alter_extension_json_file,
@@ -628,7 +629,9 @@ fn load_extension_from_json_file(
)
.map_err(|e| e.to_string())?;
super::canonicalize_relative_icon_path(extension_directory, &mut extension)?;
// TODO: refactor error handling
super::canonicalize_relative_icon_path(extension_directory, &mut extension)
.map_err(|e| report_error(&e, ReportErrorStyle::SingleLine))?;
Ok(extension)
}

View File

@@ -57,7 +57,7 @@ impl SearchSource for WindowManagementSearchSource {
&get_built_in_extension_directory(&tauri_app_handle),
super::EXTENSION_ID,
)
.map_err(SearchError::InternalError)?;
.map_err(|e| SearchError::InternalError { error: e })?;
let commands = extension.commands.expect("this extension has commands");
let mut hits: Vec<(Document, f64)> = Vec::new();

View File

@@ -6,6 +6,8 @@ pub(crate) mod view_extension;
use crate::common::document::ExtensionOnOpened;
use crate::common::document::ExtensionOnOpenedType;
use crate::common::document::OnOpened;
use crate::common::error::ReportErrorStyle;
use crate::common::error::report_error;
use crate::common::register::SearchSourceRegistry;
use crate::util::platform::Platform;
use crate::util::version::COCO_VERSION;
@@ -21,6 +23,7 @@ use serde::Serialize;
use serde_json::Value as Json;
use std::collections::HashMap;
use std::collections::HashSet;
use std::io;
use std::ops::Deref;
use std::path::Path;
use tauri::{AppHandle, Manager};
@@ -405,8 +408,12 @@ where
return Ok(None);
};
let Some(semver) = parse_coco_semver(&version_str) else {
return Err(serde::de::Error::custom("version string format is invalid"));
let semver = match parse_coco_semver(&version_str) {
Ok(ver) => ver,
Err(e) => {
let error_msg = report_error(&e, ReportErrorStyle::SingleLine);
return Err(serde::de::Error::custom(&error_msg));
}
};
Ok(Some(semver))
@@ -592,7 +599,7 @@ pub(crate) enum QuicklinkLinkComponent {
},
}
#[derive(Debug, PartialEq, Deserialize, Serialize, Clone, Display, Copy)]
#[derive(Debug, PartialEq, Deserialize, Serialize, Clone, Display, Copy, Eq)]
#[serde(rename_all(serialize = "snake_case", deserialize = "snake_case"))]
pub enum ExtensionType {
#[display("Group")]
@@ -980,11 +987,11 @@ pub(crate) async fn is_extension_enabled(
pub(crate) fn canonicalize_relative_icon_path(
extension_dir: &Path,
extension: &mut Extension,
) -> Result<(), String> {
) -> Result<(), io::Error> {
fn _canonicalize_relative_icon_path(
extension_dir: &Path,
extension: &mut Extension,
) -> Result<(), String> {
) -> Result<(), io::Error> {
let icon_str = &extension.icon;
let icon_path = Path::new(icon_str);
@@ -1003,7 +1010,7 @@ pub(crate) fn canonicalize_relative_icon_path(
assets_directory
};
if absolute_icon_path.try_exists().map_err(|e| e.to_string())? {
if absolute_icon_path.try_exists()? {
extension.icon = absolute_icon_path
.into_os_string()
.into_string()
@@ -1046,11 +1053,11 @@ pub(crate) fn canonicalize_relative_icon_path(
pub(crate) fn canonicalize_relative_page_path(
extension_dir: &Path,
extension: &mut Extension,
) -> Result<(), String> {
) -> Result<(), io::Error> {
fn _canonicalize_view_extension_page_path(
extension_dir: &Path,
extension: &mut Extension,
) -> Result<(), String> {
) -> Result<(), io::Error> {
let page = extension
.page
.as_ref()
@@ -1068,7 +1075,7 @@ pub(crate) fn canonicalize_relative_page_path(
if page_path.is_relative() {
let absolute_page_path = extension_dir.join(page_path);
if absolute_page_path.try_exists().map_err(|e| e.to_string())? {
if absolute_page_path.try_exists()? {
extension.page = Some(
absolute_page_path
.into_os_string()

View File

@@ -14,14 +14,67 @@
use crate::extension::Extension;
use crate::extension::ExtensionType;
use crate::extension::PLUGIN_JSON_FIELD_MINIMUM_COCO_VERSION;
use crate::util::platform::Platform;
use derive_more::Display;
use serde::Serialize;
use std::collections::HashSet;
use std::error::Error;
use std::fmt::Display;
pub(crate) fn general_check(extension: &Extension) -> Result<(), String> {
/// Errors that may be found when we check() `plugin.json`, i.e., `struct Extension`
#[derive(Debug, Serialize)]
pub(crate) struct InvalidPluginJsonError {
kind: InvalidPluginJsonErrorKind,
/// Some if it is a sub-extension rather than the main extension that is
/// invalid
sub_extension_id: Option<String>,
}
impl Display for InvalidPluginJsonError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if let Some(ref sub_extension_id) = self.sub_extension_id {
write!(f, "invalid sub-extension '{}'", sub_extension_id)?;
}
write!(f, "{}", self.kind)
}
}
impl Error for InvalidPluginJsonError {}
#[derive(Debug, Display, PartialEq, Eq, Serialize)]
pub(crate) enum InvalidPluginJsonErrorKind {
#[display("duplicate ID, sub-extension with ID '{}' already exists", id)]
DuplicateSubExtensionId { id: String },
#[display(
"fields '{:?}' are not allowed for extensions of type '{}'",
fields,
ty
)]
FieldsNotAllowed {
fields: &'static [&'static str],
ty: ExtensionType,
},
#[display("fields '{:?}' are not allowed for sub-extensions", fields)]
FieldsNotAllowedForSubExtension { fields: &'static [&'static str] },
#[display("sub-extensions cannot be of types {:?}", types)]
TypesNotAllowedForSubExtension { types: &'static [ExtensionType] },
#[display(
"it supports platforms {:?} that are not supported by the main extension",
extra_platforms
)]
SubExtensionHasMoreSupportedPlatforms { extra_platforms: Vec<String> },
#[display("an extensions of type '{}' should have field '{}' set", ty, field)]
FieldRequired {
field: &'static str,
ty: ExtensionType,
},
}
pub(crate) fn general_check(extension: &Extension) -> Result<(), InvalidPluginJsonError> {
// Check main extension
check_main_extension_only(extension)?;
check_main_extension_or_sub_extension(extension, &format!("extension [{}]", extension.id))?;
check_main_extension_or_sub_extension(extension, false)?;
// `None` if `extension` is compatible with all the platforms. Otherwise `Some(limited_platforms)`
let limited_supported_platforms = match extension.platforms.as_ref() {
@@ -56,18 +109,17 @@ pub(crate) fn general_check(extension: &Extension) -> Result<(), String> {
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),
)?;
check_sub_extension_only(sub_extension, limited_supported_platforms)?;
check_main_extension_or_sub_extension(extension, true)?;
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
));
return Err(InvalidPluginJsonError {
sub_extension_id: Some(sub_extension.id.clone()),
kind: InvalidPluginJsonErrorKind::DuplicateSubExtensionId {
id: sub_extension.id.clone(),
},
});
}
}
@@ -75,27 +127,33 @@ pub(crate) fn general_check(extension: &Extension) -> Result<(), String> {
}
/// This checks the main extension only, it won't check sub-extensions.
fn check_main_extension_only(extension: &Extension) -> Result<(), String> {
fn check_main_extension_only(extension: &Extension) -> Result<(), InvalidPluginJsonError> {
// Helper closure to construct `InvalidPluginJsonError` easily.
let err = |kind| InvalidPluginJsonError {
sub_extension_id: None,
kind,
};
// 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
));
}
if extension.alias.is_some()
&& (extension.r#type == ExtensionType::Group
|| extension.r#type == ExtensionType::Extension)
{
return Err(err(InvalidPluginJsonErrorKind::FieldsNotAllowed {
fields: &["alias"],
ty: 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.hotkey.is_some()
&& (extension.r#type == ExtensionType::Group
|| extension.r#type == ExtensionType::Extension)
{
return Err(err(InvalidPluginJsonErrorKind::FieldsNotAllowed {
fields: &["hotkey"],
ty: extension.r#type,
}));
}
if extension.commands.is_some()
@@ -105,20 +163,20 @@ fn check_main_extension_only(extension: &Extension) -> Result<(), String> {
{
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,
));
return Err(err(InvalidPluginJsonErrorKind::FieldsNotAllowed {
fields: &["commands", "scripts", "quicklinks", "views"],
ty: extension.r#type,
}));
}
}
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
));
return Err(err(InvalidPluginJsonErrorKind::FieldsNotAllowed {
fields: &["settings"],
ty: extension.r#type,
}));
}
}
@@ -126,16 +184,21 @@ fn check_main_extension_only(extension: &Extension) -> Result<(), String> {
}
fn check_sub_extension_only(
extension_id: &str,
sub_extension: &Extension,
limited_platforms: Option<&HashSet<Platform>>,
) -> Result<(), String> {
) -> Result<(), InvalidPluginJsonError> {
let err = |kind| InvalidPluginJsonError {
sub_extension_id: Some(sub_extension.id.clone()),
kind,
};
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
return Err(err(
InvalidPluginJsonErrorKind::TypesNotAllowedForSubExtension {
types: &[ExtensionType::Group, ExtensionType::Extension],
},
));
}
@@ -144,16 +207,18 @@ fn check_sub_extension_only(
|| sub_extension.quicklinks.is_some()
|| sub_extension.views.is_some()
{
return Err(format!(
"invalid sub-extension [{}-{}]: fields [commands/scripts/quicklinks/views] should not be set in sub-extensions",
extension_id, sub_extension.id
return Err(err(
InvalidPluginJsonErrorKind::FieldsNotAllowedForSubExtension {
fields: &["commands", "scripts", "quicklinks", "views"],
},
));
}
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
return Err(err(
InvalidPluginJsonErrorKind::FieldsNotAllowedForSubExtension {
fields: &["developer"],
},
));
}
@@ -167,9 +232,10 @@ fn check_sub_extension_only(
.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
return Err(err(
InvalidPluginJsonErrorKind::SubExtensionHasMoreSupportedPlatforms {
extra_platforms: diff,
},
));
}
}
@@ -181,9 +247,10 @@ fn check_sub_extension_only(
}
if sub_extension.minimum_coco_version.is_some() {
return Err(format!(
"invalid sub-extension [{}-{}]: [{}] cannot be set for sub-extensions",
extension_id, sub_extension.id, PLUGIN_JSON_FIELD_MINIMUM_COCO_VERSION
return Err(err(
InvalidPluginJsonErrorKind::FieldsNotAllowedForSubExtension {
fields: &["minimum_coco_version"],
},
));
}
@@ -192,59 +259,64 @@ fn check_sub_extension_only(
fn check_main_extension_or_sub_extension(
extension: &Extension,
identifier: &str,
) -> Result<(), String> {
is_sub_extension: bool,
) -> Result<(), InvalidPluginJsonError> {
let err = |kind| InvalidPluginJsonError {
kind,
sub_extension_id: is_sub_extension.then(|| extension.id.clone()),
};
// 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
));
return Err(err(InvalidPluginJsonErrorKind::FieldsNotAllowed {
fields: &["action"],
ty: extension.r#type,
}));
}
if extension.r#type == ExtensionType::Command && extension.action.is_none() {
return Err(format!(
"invalid {}, field [action] should be set for a Command extension",
identifier
));
return Err(err(InvalidPluginJsonErrorKind::FieldRequired {
field: "action",
ty: extension.r#type,
}));
}
// 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
));
return Err(err(InvalidPluginJsonErrorKind::FieldsNotAllowed {
fields: &["quicklink"],
ty: extension.r#type,
}));
}
if extension.r#type == ExtensionType::Quicklink && extension.quicklink.is_none() {
return Err(format!(
"invalid {}, field [quicklink] should be set for a Quicklink extension",
identifier
));
return Err(err(InvalidPluginJsonErrorKind::FieldRequired {
field: "quicklink",
ty: extension.r#type,
}));
}
// If field `page` is Some, then it should be a View
if extension.page.is_some() && extension.r#type != ExtensionType::View {
return Err(format!(
"invalid {}, field [page] is set for a non-View extension",
identifier
));
return Err(err(InvalidPluginJsonErrorKind::FieldsNotAllowed {
fields: &["page"],
ty: extension.r#type,
}));
}
if extension.r#type == ExtensionType::View && extension.page.is_none() {
return Err(format!(
"invalid {}, field [page] should be set for a View extension",
identifier
));
return Err(err(InvalidPluginJsonErrorKind::FieldRequired {
field: "page",
ty: extension.r#type,
}));
}
// If field `ui` is Some, then it should be a View
if extension.ui.is_some() && extension.r#type != ExtensionType::View {
return Err(format!(
"invalid {}, field [ui] is set for a non-View extension",
identifier
));
return Err(err(InvalidPluginJsonErrorKind::FieldsNotAllowed {
fields: &["ui"],
ty: extension.r#type,
}));
}
Ok(())
@@ -313,15 +385,28 @@ mod tests {
}
}
fn expect_error_kind(
result: Result<(), InvalidPluginJsonError>,
expected_kind: InvalidPluginJsonErrorKind,
) -> InvalidPluginJsonError {
let err = result.expect_err("expected Err but got Ok");
assert_eq!(&err.kind, &expected_kind);
err
}
/* 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"));
expect_error_kind(
general_check(&extension),
InvalidPluginJsonErrorKind::FieldsNotAllowed {
fields: &["alias"],
ty: ExtensionType::Group,
},
);
}
#[test]
@@ -329,9 +414,13 @@ mod tests {
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"));
expect_error_kind(
general_check(&extension),
InvalidPluginJsonErrorKind::FieldsNotAllowed {
fields: &["alias"],
ty: ExtensionType::Extension,
},
);
}
#[test]
@@ -339,9 +428,13 @@ mod tests {
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"));
expect_error_kind(
general_check(&extension),
InvalidPluginJsonErrorKind::FieldsNotAllowed {
fields: &["hotkey"],
ty: ExtensionType::Group,
},
);
}
#[test]
@@ -349,9 +442,13 @@ mod tests {
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"));
expect_error_kind(
general_check(&extension),
InvalidPluginJsonErrorKind::FieldsNotAllowed {
fields: &["hotkey"],
ty: ExtensionType::Extension,
},
);
}
#[test]
@@ -363,12 +460,12 @@ mod tests {
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")
expect_error_kind(
general_check(&extension),
InvalidPluginJsonErrorKind::FieldsNotAllowed {
fields: &["commands", "scripts", "quicklinks", "views"],
ty: ExtensionType::Command,
},
);
}
@@ -378,20 +475,24 @@ mod tests {
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")
expect_error_kind(
general_check(&extension),
InvalidPluginJsonErrorKind::FieldsNotAllowed {
fields: &["settings"],
ty: ExtensionType::Group,
},
);
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")
expect_error_kind(
general_check(&extension),
InvalidPluginJsonErrorKind::FieldsNotAllowed {
fields: &["settings"],
ty: ExtensionType::Extension,
},
);
}
/* test_check_main_extension_only */
@@ -401,12 +502,12 @@ mod tests {
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")
expect_error_kind(
general_check(&extension),
InvalidPluginJsonErrorKind::FieldRequired {
field: "action",
ty: ExtensionType::Command,
},
);
}
@@ -415,12 +516,12 @@ mod tests {
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")
expect_error_kind(
general_check(&extension),
InvalidPluginJsonErrorKind::FieldsNotAllowed {
fields: &["action"],
ty: ExtensionType::Script,
},
);
}
@@ -428,12 +529,12 @@ mod tests {
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")
expect_error_kind(
general_check(&extension),
InvalidPluginJsonErrorKind::FieldRequired {
field: "quicklink",
ty: ExtensionType::Quicklink,
},
);
}
@@ -443,12 +544,12 @@ mod tests {
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")
expect_error_kind(
general_check(&extension),
InvalidPluginJsonErrorKind::FieldsNotAllowed {
fields: &["quicklink"],
ty: ExtensionType::Command,
},
);
}
@@ -458,12 +559,12 @@ mod tests {
// create_basic_extension() will set its page field if type is View, clear it
extension.page = None;
let result = general_check(&extension);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.contains("field [page] should be set for a View extension")
expect_error_kind(
general_check(&extension),
InvalidPluginJsonErrorKind::FieldRequired {
field: "page",
ty: ExtensionType::View,
},
);
}
@@ -473,12 +574,12 @@ mod tests {
extension.action = Some(create_command_action());
extension.page = Some("index.html".into());
let result = general_check(&extension);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.contains("field [page] is set for a non-View extension")
expect_error_kind(
general_check(&extension),
InvalidPluginJsonErrorKind::FieldsNotAllowed {
fields: &["page"],
ty: ExtensionType::Command,
},
);
}
/* test check_main_extension_or_sub_extension */
@@ -490,12 +591,11 @@ mod tests {
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]")
expect_error_kind(
general_check(&extension),
InvalidPluginJsonErrorKind::TypesNotAllowedForSubExtension {
types: &[ExtensionType::Group, ExtensionType::Extension],
},
);
}
@@ -505,12 +605,11 @@ mod tests {
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]")
expect_error_kind(
general_check(&extension),
InvalidPluginJsonErrorKind::TypesNotAllowedForSubExtension {
types: &[ExtensionType::Group, ExtensionType::Extension],
},
);
}
@@ -523,12 +622,11 @@ mod tests {
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")
expect_error_kind(
general_check(&extension),
InvalidPluginJsonErrorKind::FieldsNotAllowedForSubExtension {
fields: &["developer"],
},
);
}
@@ -544,11 +642,12 @@ mod tests {
extension.commands = Some(vec![sub_cmd]);
let result = general_check(&extension);
assert!(result.is_err());
assert!(result.unwrap_err().contains(
"fields [commands/scripts/quicklinks/views] should not be set in sub-extensions"
));
expect_error_kind(
general_check(&extension),
InvalidPluginJsonErrorKind::FieldsNotAllowedForSubExtension {
fields: &["commands", "scripts", "quicklinks", "views"],
},
);
}
#[test]
@@ -558,12 +657,12 @@ mod tests {
sub_cmd.minimum_coco_version = Some(semver::Version::new(0, 8, 0));
extension.commands = Some(vec![sub_cmd]);
let result = general_check(&extension);
assert!(result.is_err());
assert!(result.unwrap_err().contains(&format!(
"[{}] cannot be set for sub-extensions",
PLUGIN_JSON_FIELD_MINIMUM_COCO_VERSION
)));
expect_error_kind(
general_check(&extension),
InvalidPluginJsonErrorKind::FieldsNotAllowedForSubExtension {
fields: &["minimum_coco_version"],
},
);
}
/* Test check_sub_extension_only */
@@ -579,12 +678,11 @@ mod tests {
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")
expect_error_kind(
general_check(&extension),
InvalidPluginJsonErrorKind::DuplicateSubExtensionId {
id: "duplicate-id".to_string(),
},
);
}
@@ -600,12 +698,11 @@ mod tests {
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")
expect_error_kind(
general_check(&extension),
InvalidPluginJsonErrorKind::DuplicateSubExtensionId {
id: "same-id".to_string(),
},
);
}
@@ -768,12 +865,12 @@ mod tests {
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
expect_error_kind(
general_check(&main_extension),
InvalidPluginJsonErrorKind::SubExtensionHasMoreSupportedPlatforms {
extra_platforms: vec!["Linux".to_string()],
},
);
}
#[test]
@@ -789,12 +886,12 @@ mod tests {
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
expect_error_kind(
general_check(&main_extension),
InvalidPluginJsonErrorKind::SubExtensionHasMoreSupportedPlatforms {
extra_platforms: vec!["Linux".to_string()],
},
);
}
#[test]

View File

@@ -0,0 +1,74 @@
use super::super::check::InvalidPluginJsonError;
use crate::common::error::serialize_error;
use crate::extension::third_party::install::ParsingMinimumCocoVersionError;
use crate::server::http_client::HttpRequestError;
use crate::util::platform::Platform;
use serde::Serialize;
use snafu::prelude::*;
use std::collections::HashSet;
use std::ffi::OsString;
use std::io;
use std::path::PathBuf;
#[derive(Debug, Snafu, Serialize)]
#[snafu(visibility(pub(crate)))]
pub(crate) enum InvalidExtensionError {
#[snafu(display("path '{}' contains no filename", path.display()))]
NoFileName { path: PathBuf },
#[snafu(display("'{}' is not UTF-8 encoded", os_str.display()))]
NonUtf8Encoding { os_str: OsString },
#[snafu(display("file 'plugin.json' does not exist"))]
MissingPluginJson,
#[snafu(display("failed to read 'plugin.json'"))]
ReadPluginJson {
#[serde(serialize_with = "serialize_error")]
source: io::Error,
},
#[snafu(display("failed to decode 'plugin.json'"))]
DecodePluginJson {
#[serde(serialize_with = "serialize_error")]
source: serde_json::Error,
},
#[snafu(display("'plugin.json' is invalid"))]
InvalidPluginJson { source: InvalidPluginJsonError },
#[snafu(display("failed to parse field 'minimum_coco_version'"))]
ParseMinimumCocoVersion {
source: ParsingMinimumCocoVersionError,
},
}
#[derive(Debug, Snafu, Serialize)]
#[snafu(visibility(pub(crate)))]
pub(crate) enum InstallExtensionError {
#[snafu(display("extension is invalid"))]
InvalidExtension { source: InvalidExtensionError },
#[snafu(display("extension '{}' does not exist", id))]
NotFound { id: String },
#[snafu(display("failed to download extension"))]
DownloadFailure { source: HttpRequestError },
#[snafu(display("failed to decode the downloaded archive"))]
ZipArchiveDecodingError {
#[serde(serialize_with = "serialize_error")]
source: zip::result::ZipError,
},
#[snafu(display("extension is already installed"))]
AlreadyInstalled,
#[snafu(display(
"extension is incompatible with your current platform '{}', it can be installed on '{:?}'",
current_platform,
// Use Display print instead of Debug
compatible_platforms.into_iter().map(|p|p.to_string()).collect::<Vec<String>>(),
))]
IncompatiblePlatform {
current_platform: Platform,
compatible_platforms: HashSet<Platform>,
},
#[snafu(display("extension is incompatible with your Coco AI app",))]
// TODO: include the actual 'minimum_coco_version' in the Display impl
IncompatibleCocoApp,
#[snafu(display("I/O Error"))]
IoError {
#[serde(serialize_with = "serialize_error")]
source: io::Error,
},
}

View File

@@ -1,6 +1,15 @@
use super::check_compatibility_via_mcv;
use super::error::InstallExtensionError;
use super::error::InvalidExtensionSnafu;
use crate::common::error::ReportErrorStyle;
use crate::common::error::report_error;
use crate::extension::PLUGIN_JSON_FILE_NAME;
use crate::extension::third_party::check::general_check;
use crate::extension::third_party::install::error::DecodePluginJsonSnafu;
use crate::extension::third_party::install::error::InvalidExtensionError;
use crate::extension::third_party::install::error::InvalidPluginJsonSnafu;
use crate::extension::third_party::install::error::IoSnafu;
use crate::extension::third_party::install::error::ParseMinimumCocoVersionSnafu;
use crate::extension::third_party::install::{
filter_out_incompatible_sub_extensions, is_extension_installed,
};
@@ -12,6 +21,9 @@ use crate::extension::{
};
use crate::util::platform::Platform;
use serde_json::Value as Json;
use snafu::ResultExt;
use std::io;
use std::io::ErrorKind as IoErrorKind;
use std::path::Path;
use std::path::PathBuf;
use tauri::AppHandle;
@@ -36,52 +48,53 @@ const DEVELOPER_ID_LOCAL: &str = "__local__";
pub(crate) async fn install_local_extension(
tauri_app_handle: AppHandle,
path: PathBuf,
) -> Result<(), String> {
) -> Result<(), InstallExtensionError> {
let extension_dir_name = path
.file_name()
.ok_or_else(|| "Invalid extension: no directory name".to_string())?
.ok_or_else(|| InvalidExtensionError::NoFileName { path: path.clone() })
.context(InvalidExtensionSnafu)?
.to_str()
.ok_or_else(|| "Invalid extension: non-UTF8 extension id".to_string())?;
.ok_or_else(|| InvalidExtensionError::NonUtf8Encoding {
os_str: path.clone().into_os_string(),
})
.context(InvalidExtensionSnafu)?;
// 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());
return Err(InstallExtensionError::AlreadyInstalled);
}
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())?;
let plugin_json_content = match fs::read_to_string(&plugin_json_path).await {
Ok(content) => content,
Err(io_err) => {
let io_err_kind = io_err.kind();
if io_err_kind == IoErrorKind::NotFound {
return Err(InstallExtensionError::InvalidExtension {
source: InvalidExtensionError::MissingPluginJson,
});
} else {
return Err(InstallExtensionError::InvalidExtension {
source: InvalidExtensionError::ReadPluginJson { source: io_err },
});
}
}
};
// 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())?;
let mut extension_json: Json = serde_json::from_str(&plugin_json_content)
.context(DecodePluginJsonSnafu)
.context(InvalidExtensionSnafu)?;
if !check_compatibility_via_mcv(&extension_json)? {
return Err("app_incompatible".into());
let compatible_with_app = check_compatibility_via_mcv(&extension_json)
.context(ParseMinimumCocoVersionSnafu)
.context(InvalidExtensionSnafu)?;
if !compatible_with_app {
return Err(InstallExtensionError::IncompatibleCocoApp);
}
// Set the main extension ID to the directory name
@@ -134,36 +147,22 @@ pub(crate) async fn install_local_extension(
}
// 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 mut extension: Extension = serde_json::from_value(extension_json)
.context(DecodePluginJsonSnafu)
.context(InvalidExtensionSnafu)?;
let current_platform = Platform::current();
/* Check begins here */
general_check(&extension)?;
general_check(&extension)
.context(InvalidPluginJsonSnafu)
.context(InvalidExtensionSnafu)?;
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("platform_incompatible".into());
return Err(InstallExtensionError::IncompatiblePlatform {
current_platform,
compatible_platforms: platforms.clone(),
});
}
}
/* Check ends here */
@@ -185,18 +184,19 @@ pub(crate) async fn install_local_extension(
.join(DEVELOPER_ID_LOCAL)
.join(extension_dir_name);
fs::create_dir_all(&dest_dir)
.await
.map_err(|e| e.to_string())?;
fs::create_dir_all(&dest_dir).await.context(IoSnafu)?;
// Copy all files except plugin.json
let mut entries = fs::read_dir(&path).await.map_err(|e| e.to_string())?;
let mut entries = fs::read_dir(&path).await.context(IoSnafu)?;
while let Some(entry) = entries.next_entry().await.map_err(|e| e.to_string())? {
while let Some(entry) = entries.next_entry().await.context(IoSnafu)? {
let file_name = entry.file_name();
let file_name_str = file_name
.to_str()
.ok_or_else(|| "Invalid filename: non-UTF8".to_string())?;
.ok_or_else(|| InvalidExtensionError::NonUtf8Encoding {
os_str: file_name.clone(),
})
.context(InvalidExtensionSnafu)?;
// plugin.json will be handled separately.
if file_name_str == PLUGIN_JSON_FILE_NAME {
@@ -208,27 +208,32 @@ pub(crate) async fn install_local_extension(
if src_path.is_dir() {
// Recursively copy directory
copy_dir_recursively(&src_path, &dest_path).await?;
copy_dir_recursively(&src_path, &dest_path)
.await
.context(IoSnafu)?;
} else {
// Copy file
fs::copy(&src_path, &dest_path)
.await
.map_err(|e| e.to_string())?;
fs::copy(&src_path, &dest_path).await.context(IoSnafu)?;
}
}
// Write the corrected plugin.json file
let corrected_plugin_json =
serde_json::to_string_pretty(&extension).map_err(|e| e.to_string())?;
let corrected_plugin_json = serde_json::to_string_pretty(&extension).unwrap_or_else(|e| {
panic!(
"failed to serialize extension {:?}, error:\n{}",
extension,
report_error(&e, ReportErrorStyle::MultipleLines)
)
});
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())?;
.context(IoSnafu)?;
// Canonicalize relative icon and page paths
canonicalize_relative_icon_path(&dest_dir, &mut extension)?;
canonicalize_relative_page_path(&dest_dir, &mut extension)?;
canonicalize_relative_icon_path(&dest_dir, &mut extension).context(IoSnafu)?;
canonicalize_relative_page_path(&dest_dir, &mut extension).context(IoSnafu)?;
// Add extension to the search source
third_party_ext_list_write_lock.push(extension);
@@ -238,22 +243,18 @@ pub(crate) async fn install_local_extension(
/// 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())?;
async fn copy_dir_recursively(src: &Path, dest: &Path) -> Result<(), io::Error> {
tokio::fs::create_dir_all(dest).await?;
let mut read_dir = tokio::fs::read_dir(src).await?;
while let Some(entry) = read_dir.next_entry().await.map_err(|e| e.to_string())? {
while let Some(entry) = read_dir.next_entry().await? {
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())?;
tokio::fs::copy(&src_path, &dest_path).await?;
}
}

View File

@@ -41,14 +41,18 @@
//!
//! 7. Add the extension to the in-memory extension list.
pub(crate) mod error;
pub(crate) mod local_extension;
pub(crate) mod store;
use crate::extension::Extension;
use crate::extension::PLUGIN_JSON_FIELD_MINIMUM_COCO_VERSION;
use crate::util::platform::Platform;
use crate::util::version::ParseVersionError;
use crate::util::version::{COCO_VERSION, parse_coco_semver};
use serde::Serialize;
use serde_json::Value as Json;
use snafu::prelude::*;
use std::ops::Deref;
use super::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE;
@@ -121,9 +125,17 @@ pub(crate) fn filter_out_incompatible_sub_extensions(
}
}
#[derive(Debug, Snafu, Serialize)]
pub(crate) enum ParsingMinimumCocoVersionError {
#[snafu(display("field 'minimum_coco_version' should be a string, but it is not"))]
MismatchType,
#[snafu(display("failed to parse field 'minimum_coco_version'"))]
ParsingVersionError { source: ParseVersionError },
}
/// Inspect the "minimum_coco_version" field and see if this extension is
/// compatible with the current Coco app.
fn check_compatibility_via_mcv(plugin_json: &Json) -> Result<bool, String> {
fn check_compatibility_via_mcv(plugin_json: &Json) -> Result<bool, ParsingMinimumCocoVersionError> {
let Some(mcv_json) = plugin_json.get(PLUGIN_JSON_FIELD_MINIMUM_COCO_VERSION) else {
return Ok(true);
};
@@ -132,18 +144,10 @@ fn check_compatibility_via_mcv(plugin_json: &Json) -> Result<bool, String> {
}
let Some(mcv_str) = mcv_json.as_str() else {
return Err(format!(
"invalid extension: field [{}] should be a string",
PLUGIN_JSON_FIELD_MINIMUM_COCO_VERSION
));
return Err(ParsingMinimumCocoVersionError::MismatchType);
};
let Some(mcv) = parse_coco_semver(mcv_str) else {
return Err(format!(
"invalid extension: [{}] is not a valid version string",
PLUGIN_JSON_FIELD_MINIMUM_COCO_VERSION
));
};
let mcv = parse_coco_semver(mcv_str).context(ParsingVersionSnafu)?;
Ok(COCO_VERSION.deref() >= &mcv)
}

View File

@@ -5,7 +5,9 @@ use super::check_compatibility_via_mcv;
use super::is_extension_installed;
use crate::common::document::DataSourceReference;
use crate::common::document::Document;
use crate::common::error::ReportErrorStyle;
use crate::common::error::SearchError;
use crate::common::error::report_error;
use crate::common::search::QueryResponse;
use crate::common::search::QuerySource;
use crate::common::search::SearchQuery;
@@ -17,13 +19,24 @@ use crate::extension::canonicalize_relative_icon_path;
use crate::extension::canonicalize_relative_page_path;
use crate::extension::third_party::check::general_check;
use crate::extension::third_party::get_third_party_extension_directory;
use crate::extension::third_party::install::error::DecodePluginJsonSnafu;
use crate::extension::third_party::install::error::DownloadFailureSnafu;
use crate::extension::third_party::install::error::InstallExtensionError;
use crate::extension::third_party::install::error::InvalidExtensionError;
use crate::extension::third_party::install::error::InvalidExtensionSnafu;
use crate::extension::third_party::install::error::InvalidPluginJsonSnafu;
use crate::extension::third_party::install::error::IoSnafu;
use crate::extension::third_party::install::error::ParseMinimumCocoVersionSnafu;
use crate::extension::third_party::install::error::ZipArchiveDecodingSnafu;
use crate::extension::third_party::install::filter_out_incompatible_sub_extensions;
use crate::server::http_client::DecodeResponseSnafu;
use crate::server::http_client::HttpClient;
use crate::util::platform::Platform;
use async_trait::async_trait;
use reqwest::StatusCode;
use serde_json::Map as JsonObject;
use serde_json::Value as Json;
use snafu::ResultExt;
use std::io::Read;
use tauri::AppHandle;
@@ -233,24 +246,24 @@ pub(crate) async fn extension_detail(
pub(crate) async fn install_extension_from_store(
tauri_app_handle: AppHandle,
id: String,
) -> Result<(), String> {
) -> Result<(), InstallExtensionError> {
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))?;
.context(DownloadFailureSnafu)?;
if response.status() == StatusCode::NOT_FOUND {
return Err(format!("extension [{}] not found", id));
return Err(InstallExtensionError::NotFound { id });
}
let bytes = response
.bytes()
.await
.map_err(|e| format!("Failed to read response bytes: {}", e))?;
.context(DecodeResponseSnafu)
.context(DownloadFailureSnafu)?;
let cursor = std::io::Cursor::new(bytes);
let mut archive =
zip::ZipArchive::new(cursor).map_err(|e| format!("Failed to read zip archive: {}", e))?;
let mut archive = zip::ZipArchive::new(cursor).context(ZipArchiveDecodingSnafu)?;
// The plugin.json sent from the server does not conform to our `struct Extension` definition:
//
@@ -260,27 +273,48 @@ pub(crate) async fn install_extension_from_store(
// we need to correct it
let mut plugin_json = archive
.by_name(PLUGIN_JSON_FILE_NAME)
.map_err(|e| e.to_string())?;
.context(ZipArchiveDecodingSnafu)?;
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))?;
if !check_compatibility_via_mcv(&extension)? {
return Err("app_incompatible".into());
std::io::Read::read_to_string(&mut plugin_json, &mut plugin_json_content).context(IoSnafu)?;
let mut extension: Json = serde_json::from_str(&plugin_json_content)
.context(DecodePluginJsonSnafu)
.context(InvalidExtensionSnafu)?;
let compatible_with_app = check_compatibility_via_mcv(&extension)
.context(ParseMinimumCocoVersionSnafu)
.context(InvalidExtensionSnafu)?;
if !compatible_with_app {
return Err(InstallExtensionError::IncompatibleCocoApp);
}
let mut_ref_to_developer_object: &mut Json = extension
let extension_object = extension
.as_object_mut()
.expect("plugin.json should be an object")
.ok_or_else(|| InvalidExtensionError::DecodePluginJson {
source: serde::de::Error::custom("plugin.json should be an object"),
})
.context(InvalidExtensionSnafu)?;
let mut_ref_to_developer_object: &mut Json = extension_object
.get_mut("developer")
.expect("plugin.json should contain field [developer]");
.ok_or_else(|| InvalidExtensionError::DecodePluginJson {
source: serde::de::Error::missing_field("developer"),
})
.context(InvalidExtensionSnafu)?;
let developer_id = mut_ref_to_developer_object
.get("id")
.expect("plugin.json should contain [developer.id]")
.ok_or_else(|| InvalidExtensionError::DecodePluginJson {
source: serde::de::Error::missing_field("id"),
})
.context(InvalidExtensionSnafu)?
.as_str()
.expect("plugin.json field [developer.id] should be a string");
.ok_or_else(|| InvalidExtensionError::DecodePluginJson {
source: serde::de::Error::custom("field 'id' should be of type 'string'"),
})
.context(InvalidExtensionSnafu)?;
*mut_ref_to_developer_object = Json::String(developer_id.into());
// Set IDs for sub-extensions (commands, quicklinks, scripts)
@@ -305,27 +339,33 @@ pub(crate) async fn install_extension_from_store(
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");
let mut extension: Extension = serde_json::from_value(extension)
.context(DecodePluginJsonSnafu)
.context(InvalidExtensionSnafu)?;
let developer_id = extension
.developer
.clone()
.expect("we checked this field exists");
drop(plugin_json);
general_check(&extension)?;
general_check(&extension)
.context(InvalidPluginJsonSnafu)
.context(InvalidExtensionSnafu)?;
let current_platform = Platform::current();
if let Some(ref platforms) = extension.platforms {
if !platforms.contains(&current_platform) {
return Err("platform_incompatible".into());
return Err(InstallExtensionError::IncompatiblePlatform {
current_platform,
compatible_platforms: platforms.clone(),
});
}
}
if is_extension_installed(&developer_id, &id).await {
return Err("Extension already installed.".into());
return Err(InstallExtensionError::AlreadyInstalled);
}
// Extension is compatible with current platform, but it could contain sub
@@ -350,11 +390,11 @@ pub(crate) async fn install_extension_from_store(
};
tokio::fs::create_dir_all(extension_directory.as_path())
.await
.map_err(|e| e.to_string())?;
.context(IoSnafu)?;
// 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())?;
let mut zip_file = archive.by_index(i).context(ZipArchiveDecodingSnafu)?;
// `.name()` is safe to use in our cases, the cases listed in the below
// page won't happen to us.
//
@@ -382,35 +422,39 @@ pub(crate) async fn install_extension_from_store(
{
tokio::fs::create_dir_all(parent_dir)
.await
.map_err(|e| e.to_string())?;
.context(IoSnafu)?;
}
let mut dest_file = tokio::fs::File::create(&dest_file_path)
.await
.map_err(|e| e.to_string())?;
.context(IoSnafu)?;
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())?;
zip_file.read_to_end(&mut src_bytes).context(IoSnafu)?;
tokio::io::copy(&mut src_bytes.as_slice(), &mut dest_file)
.await
.map_err(|e| e.to_string())?;
.context(IoSnafu)?;
}
// 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())?;
let extension_json = serde_json::to_string_pretty(&extension).unwrap_or_else(|e| {
panic!(
"failed to serialize extension {:?}, error:\n{}",
extension,
report_error(&e, ReportErrorStyle::MultipleLines)
)
});
tokio::fs::write(&plugin_json_path, extension_json)
.await
.map_err(|e| e.to_string())?;
.context(IoSnafu)?;
// Canonicalize relative icon and page paths
canonicalize_relative_icon_path(&extension_directory, &mut extension)?;
canonicalize_relative_page_path(&extension_directory, &mut extension)?;
canonicalize_relative_icon_path(&extension_directory, &mut extension).context(IoSnafu)?;
canonicalize_relative_page_path(&extension_directory, &mut extension).context(IoSnafu)?;
third_party_ext_list_write_lock.push(extension);

View File

@@ -9,7 +9,9 @@ use super::canonicalize_relative_icon_path;
use crate::common::document::DataSourceReference;
use crate::common::document::Document;
use crate::common::document::open;
use crate::common::error::ReportErrorStyle;
use crate::common::error::SearchError;
use crate::common::error::report_error;
use crate::common::search::QueryResponse;
use crate::common::search::QuerySource;
use crate::common::search::SearchQuery;
@@ -159,13 +161,17 @@ pub(crate) async fn load_third_party_extensions_from_directory(
continue 'extension;
};
let Some(mcv) = parse_coco_semver(mcv_str) else {
log::warn!(
"invalid extension: [{}]: field [{}] has invalid version string",
extension_dir_file_name,
PLUGIN_JSON_FIELD_MINIMUM_COCO_VERSION
);
continue 'extension;
let mcv = match parse_coco_semver(mcv_str) {
Ok(ver) => ver,
Err(e) => {
log::warn!(
"invalid extension: [{}]: field [{}] has invalid version: {} ",
extension_dir_file_name,
PLUGIN_JSON_FIELD_MINIMUM_COCO_VERSION,
report_error(&e, ReportErrorStyle::SingleLine)
);
continue 'extension;
}
};
Some(mcv)
@@ -265,10 +271,8 @@ pub(crate) async fn load_third_party_extensions_from_directory(
};
// Turn icon path into an absolute path if it is a valid relative path
canonicalize_relative_icon_path(
&extension_dir.path(),
&mut incompatible_extension,
)?;
canonicalize_relative_icon_path(&extension_dir.path(), &mut incompatible_extension)
.map_err(|e| report_error(&e, ReportErrorStyle::SingleLine))?;
// No need to canonicalize the path field as it is not set
extensions.push(incompatible_extension);
@@ -339,8 +343,10 @@ pub(crate) async fn load_third_party_extensions_from_directory(
/* Check ends here */
// Turn it into an absolute path if it is a valid relative path because frontend code needs this.
canonicalize_relative_icon_path(&extension_dir.path(), &mut extension)?;
canonicalize_relative_page_path(&extension_dir.path(), &mut extension)?;
canonicalize_relative_icon_path(&extension_dir.path(), &mut extension)
.map_err(|e| report_error(&e, ReportErrorStyle::SingleLine))?;
canonicalize_relative_page_path(&extension_dir.path(), &mut extension)
.map_err(|e| report_error(&e, ReportErrorStyle::SingleLine))?;
extensions.push(extension);
}

View File

@@ -3,6 +3,7 @@ mod autostart;
mod common;
mod extension;
mod search;
mod selection_monitor;
mod server;
mod settings;
mod setup;
@@ -12,7 +13,9 @@ pub mod util;
use crate::common::register::SearchSourceRegistry;
use crate::common::{CHECK_WINDOW_LABEL, MAIN_WINDOW_LABEL, SETTINGS_WINDOW_LABEL};
use crate::server::servers::{load_or_insert_default_server, load_servers_token};
use crate::server::servers::{
load_or_insert_default_server, load_servers_token, start_bg_heartbeat_worker,
};
use crate::util::logging::set_up_tauri_logger;
use crate::util::prevent_default;
use autostart::change_autostart;
@@ -60,7 +63,9 @@ async fn change_window_height(handle: AppHandle, height: u32) {
let monitor_position = monitor.position();
let monitor_size = monitor.size();
let window_width = window.outer_size().unwrap().width as i32;
let outer_size = window.outer_size().unwrap();
let window_width = outer_size.width as i32;
let x = monitor_position.x + (monitor_size.width as i32 - window_width) / 2;
let y =
@@ -70,24 +75,13 @@ async 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,
}
// Removed unused Payload to avoid unnecessary serde derive macro invocations
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let ctx = tauri::generate_context!();
let mut app_builder = tauri::Builder::default();
let mut app_builder = tauri::Builder::default().plugin(tauri_plugin_clipboard_manager::init());
// Set up logger first
app_builder = app_builder.plugin(set_up_tauri_logger());
@@ -118,6 +112,7 @@ pub fn run() {
)
.plugin(tauri_plugin_windows_version::init())
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_zustand::init())
.plugin(prevent_default::init());
// Conditional compilation for macOS
@@ -206,7 +201,9 @@ pub fn run() {
setup::backend_setup,
util::app_lang::update_app_lang,
util::path::path_absolute,
util::logging::app_log_dir
util::logging::app_log_dir,
selection_monitor::set_selection_enabled,
selection_monitor::get_selection_enabled,
])
.setup(|app| {
#[cfg(target_os = "macos")]
@@ -216,7 +213,6 @@ pub fn run() {
log::trace!("Dock icon should be hidden now");
}
/* ----------- 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();
@@ -285,6 +281,12 @@ pub async fn init(app_handle: &AppHandle) {
.await;
}
/*
* Start the background heartbeat worker here after setting up Coco server
* storage and SearchSourceRegistry.
*/
start_bg_heartbeat_worker(app_handle.clone());
extension::built_in::pizza_engine_runtime::start_pizza_engine_runtime().await;
}

View File

@@ -69,6 +69,9 @@ fn setup_panic_hook() {
eprintln!("Panic hook error: Failed to open panic log file: {}", e);
}
}
// Write to stdout, with a new-line char since stdout is line-buffered.
println!("{}\n", panic_log);
}));
}

View File

@@ -1,10 +1,11 @@
use crate::common::error::SearchError;
use crate::common::error::{ReportErrorStyle, SearchError, report_error};
use crate::common::register::SearchSourceRegistry;
use crate::common::search::{
FailedRequest, MultiSourceQueryResponse, QueryHits, QuerySource, SearchQuery,
};
use crate::common::traits::SearchSource;
use crate::extension::LOCAL_QUERY_SOURCE_TYPE;
use crate::server::http_client::HttpRequestError;
use crate::server::servers::logout_coco_server;
use crate::server::servers::mark_server_as_offline;
use crate::settings::get_local_query_source_weight;
@@ -507,15 +508,20 @@ async fn query_coco_fusion_handle_failed_request(
let mut status_code_num: u16 = 0;
if let SearchError::HttpError {
status_code: opt_status_code,
msg: _,
} = search_error
{
if let SearchError::HttpError { source } = &search_error {
let opt_status_code = match source {
HttpRequestError::RequestFailed {
status,
error_response_body_str: _,
coco_server_api_error_response_body: _,
} => Some(status),
_ => None,
};
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 {
status_code_num = *status_code;
if *status_code != StatusCode::OK.as_u16() {
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| {
@@ -535,7 +541,7 @@ async fn query_coco_fusion_handle_failed_request(
failed_requests.push(FailedRequest {
source: query_source,
status: status_code_num,
error: Some(search_error.to_string()),
error: Some(report_error(&search_error, ReportErrorStyle::SingleLine)),
reason: None,
});
}

View File

@@ -0,0 +1,671 @@
/// Event payload sent to the frontend when selection is detected.
/// Coordinates use logical (Quartz) points with a top-left origin.
/// Note: `y` is flipped on the backend to match the frontends usage.
use tauri::Emitter;
#[derive(serde::Serialize, Clone)]
struct SelectionEventPayload {
text: String,
x: i32,
y: i32,
}
use once_cell::sync::Lazy;
use std::sync::Mutex;
use std::sync::atomic::{AtomicBool, Ordering};
/// Global toggle: selection monitoring disabled for this release.
static SELECTION_ENABLED: AtomicBool = AtomicBool::new(false);
/// Ensure we only start the monitor thread once. Allows delayed start after
/// Accessibility permission is granted post-launch.
static MONITOR_THREAD_STARTED: AtomicBool = AtomicBool::new(false);
/// Guard to avoid spawning multiple permission watcher threads.
#[cfg(target_os = "macos")]
static PERMISSION_WATCHER_STARTED: AtomicBool = AtomicBool::new(false);
/// Session flags for controlling macOS Accessibility prompts.
#[cfg(target_os = "macos")]
static SEEN_ACCESSIBILITY_TRUSTED_ONCE: AtomicBool = AtomicBool::new(false);
#[cfg(target_os = "macos")]
static LAST_ACCESSIBILITY_PROMPT: Lazy<Mutex<Option<std::time::Instant>>> =
Lazy::new(|| Mutex::new(None));
#[derive(serde::Serialize, Clone)]
struct SelectionEnabledPayload {
enabled: bool,
}
#[derive(serde::Serialize, Clone)]
struct SelectionPermissionInfo {
bundle_id: String,
exe_path: String,
in_applications: bool,
is_dmg: bool,
is_dev_guess: bool,
}
/// Read the current selection monitoring state.
pub fn is_selection_enabled() -> bool {
SELECTION_ENABLED.load(Ordering::Relaxed)
}
/// Update the monitoring state and broadcast to the frontend.
fn set_selection_enabled_internal(app_handle: &tauri::AppHandle, enabled: bool) {
SELECTION_ENABLED.store(enabled, Ordering::Relaxed);
log::info!(target: "coco_lib::selection_monitor", "selection monitoring toggled: enabled={}", enabled);
let _ = app_handle.emit("selection-enabled", SelectionEnabledPayload { enabled });
}
/// Tauri command: set selection monitoring state.
#[tauri::command]
pub fn set_selection_enabled(app_handle: tauri::AppHandle, enabled: bool) {
set_selection_enabled_internal(&app_handle, enabled);
// When enabling selection monitoring on macOS, ensure Accessibility permission.
// If not granted, trigger system prompt and deep-link to the right settings pane,
// and notify frontend to guide the user.
#[cfg(target_os = "macos")]
{
if enabled {
let trusted = ensure_accessibility_permission(&app_handle);
// If permission is now trusted and the monitor hasn't started yet,
// start it immediately to avoid requiring an app restart.
if trusted && !MONITOR_THREAD_STARTED.load(Ordering::Relaxed) {
log::info!(target: "coco_lib::selection_monitor", "set_selection_enabled: permission trusted; starting monitor thread");
start_selection_monitor(app_handle.clone());
return;
}
}
}
}
/// Tauri command: get selection monitoring state.
#[tauri::command]
pub fn get_selection_enabled() -> bool {
is_selection_enabled()
}
#[cfg(target_os = "macos")]
pub fn start_selection_monitor(app_handle: tauri::AppHandle) {
// Entrypoint: checks permissions (macOS), initializes, and starts a background watcher thread.
log::info!(target: "coco_lib::selection_monitor", "start_selection_monitor: entrypoint");
use std::time::Duration;
use tauri::Emitter;
// Sync initial enabled state to the frontend on startup.
set_selection_enabled_internal(&app_handle, is_selection_enabled());
// Accessibility permission is required to read selected text in the foreground app.
// If not granted, prompt the user once; if still not granted, skip starting the watcher.
#[cfg(target_os = "macos")]
{
// If already started, don't start twice.
if MONITOR_THREAD_STARTED.load(Ordering::Relaxed) {
log::debug!(target: "coco_lib::selection_monitor", "start_selection_monitor: already started; skipping");
return;
}
if !ensure_accessibility_permission(&app_handle) {
log::warn!(target: "coco_lib::selection_monitor", "start_selection_monitor: accessibility not granted; deferring watcher start");
// Spawn a short-lived permission watcher to auto-start once the user grants.
if !PERMISSION_WATCHER_STARTED.swap(true, Ordering::Relaxed) {
let app_handle_clone = app_handle.clone();
std::thread::Builder::new()
.name("selection-permission-watcher".into())
.spawn(move || {
use std::time::Duration;
// Persistent polling with gentle backoff: checks every 2s for ~1 minute,
// then every 10s thereafter, until either trusted or selection disabled.
let mut checks: u32 = 0;
loop {
// If user disabled selection in between, stop early.
if !is_selection_enabled() {
log::debug!(target: "coco_lib::selection_monitor", "permission watcher: selection disabled; stop polling");
break;
}
// Fast trust check without prompt.
if macos_accessibility_client::accessibility::application_is_trusted() {
log::info!(target: "coco_lib::selection_monitor", "permission watcher: accessibility granted; starting monitor");
// Reset watcher flag before starting monitor to allow future retries if needed.
PERMISSION_WATCHER_STARTED.store(false, Ordering::Relaxed);
start_selection_monitor(app_handle_clone.clone());
return;
}
// Backoff strategy.
checks += 1;
let sleep_secs = if checks <= 30 { 2 } else { 10 }; // ~1 min fast, then slower
if checks % 30 == 0 {
log::debug!(target: "coco_lib::selection_monitor", "permission watcher: still not granted; continuing to poll (checks={})", checks);
}
std::thread::sleep(Duration::from_secs(sleep_secs));
}
// Done polling without success; allow future attempts.
PERMISSION_WATCHER_STARTED.store(false, Ordering::Relaxed);
log::debug!(target: "coco_lib::selection_monitor", "permission watcher: stopped (no grant)");
})
.unwrap_or_else(|e| {
PERMISSION_WATCHER_STARTED.store(false, Ordering::Relaxed);
// Fail fast here: spawning a watcher thread is critical for deferred start.
panic!(
"permission watcher: failed to spawn: {}",
e
);
});
} else {
log::debug!(target: "coco_lib::selection_monitor", "start_selection_monitor: permission watcher already running; skip spawning");
}
return;
}
}
#[cfg(not(target_os = "macos"))]
{
log::info!(target: "coco_lib::selection_monitor", "start_selection_monitor: non-macos platform, no selection monitor");
}
// Background thread: drives popup show/hide based on mouse and AX selection state.
MONITOR_THREAD_STARTED.store(true, Ordering::Relaxed);
log::info!(target: "coco_lib::selection_monitor", "start_selection_monitor: starting watcher thread");
std::thread::spawn(move || {
#[cfg(target_os = "macos")]
use objc2_app_kit::NSWorkspace;
use objc2_core_graphics::CGEvent;
use objc2_core_graphics::{CGDisplayBounds, CGGetActiveDisplayList, CGMainDisplayID};
// Get current mouse position (logical top-left origin), flipping `y` to match frontend usage.
let current_mouse_point_global = || -> (i32, i32) {
unsafe {
let event = CGEvent::new(None);
let pt = objc2_core_graphics::CGEvent::location(event.as_deref());
// Enumerate active displays to compute global bounds and pick the display containing the cursor.
let mut displays: [u32; 16] = [0; 16];
let mut display_count: u32 = 0;
let _ = CGGetActiveDisplayList(
displays.len() as u32,
displays.as_mut_ptr(),
&mut display_count,
);
if display_count == 0 {
// Fallback to main display.
let did = CGMainDisplayID();
let b = CGDisplayBounds(did);
let min_x_pt = b.origin.x as f64;
let max_top_pt = (b.origin.y + b.size.height) as f64;
let min_bottom_pt = b.origin.y as f64;
let total_h_pt = max_top_pt - min_bottom_pt;
let x_top_left = (pt.x as f64 - min_x_pt).round() as i32;
let y_top_left = (max_top_pt - pt.y as f64).round() as i32;
let y_flipped = (total_h_pt.round() as i32 - y_top_left).max(0);
return (x_top_left, y_flipped);
}
let mut _chosen = CGMainDisplayID(); // default fallback
// log::info!(
// "current_mouse: pt=({:.1},{:.1}) → display={}",
// pt.x as f64,
// pt.y as f64,
// chosen
// );
let mut min_x_pt = f64::INFINITY;
let mut max_top_pt = f64::NEG_INFINITY;
let mut min_bottom_pt = f64::INFINITY;
for i in 0..display_count as usize {
let did = displays[i];
let b = CGDisplayBounds(did);
if (b.origin.x as f64) < min_x_pt {
min_x_pt = b.origin.x as f64;
}
let top = (b.origin.y + b.size.height) as f64;
if top > max_top_pt {
max_top_pt = top;
}
if (b.origin.y as f64) < min_bottom_pt {
min_bottom_pt = b.origin.y as f64;
}
let in_x = pt.x >= b.origin.x && pt.x <= b.origin.x + b.size.width;
let in_y = pt.y >= b.origin.y && pt.y <= b.origin.y + b.size.height;
if in_x && in_y {
_chosen = did;
// log::info!(
// "current_mouse: pt=({:.1},{:.1}) → display={} → point_global_top_left=(x={}, y={})",
// pt.x as f64,
// pt.y as f64,
// chosen,
// b.origin.x,
// b.origin.y
// );
}
}
let total_h_pt = max_top_pt - min_bottom_pt;
let x_top_left = (pt.x as f64 - min_x_pt).round() as i32;
let y_top_left = (max_top_pt - pt.y as f64).round() as i32;
let y_flipped = (total_h_pt.round() as i32 - y_top_left).max(0);
(x_top_left, y_flipped)
}
};
// Determine whether the frontmost app is this process (Coco).
// Avoid misinterpreting empty selection when interacting with the popup itself.
let is_frontmost_app_me = || -> bool {
#[cfg(target_os = "macos")]
unsafe {
let workspace = NSWorkspace::sharedWorkspace();
if let Some(frontmost) = workspace.frontmostApplication() {
let pid = frontmost.processIdentifier();
let my_pid = std::process::id() as i32;
return pid == my_pid;
}
}
false
};
// Selection-driven state machine.
let mut popup_visible = false;
let mut last_text = String::new();
// Stability and hide thresholds (tunable).
let stable_threshold = 2; // same content ≥2 times → stable selection
let empty_threshold = 2; // empty value ≥2 times → stable empty
let mut stable_text = String::new();
let mut stable_count = 0;
let mut empty_count = 0;
loop {
std::thread::sleep(Duration::from_millis(30));
// If disabled: do not read AX / do not show popup; hide if currently visible.
if !is_selection_enabled() {
if popup_visible {
let _ = app_handle.emit("selection-detected", "");
popup_visible = false;
last_text.clear();
stable_text.clear();
}
continue;
}
// Skip empty-selection hide checks while interacting with the Coco popup.
// Robust check: treat as "self" if either the frontmost app or the
// system-wide focused element belongs to this process.
let front_is_me = is_frontmost_app_me() || is_focused_element_me();
// When Coco is frontmost, disable detection but do NOT hide the popup.
// Users may be clicking the popup; we must keep it visible.
if front_is_me {
// Reset counters to avoid stale state on re-entry.
stable_count = 0;
empty_count = 0;
continue;
}
// Lightweight retries to smooth out transient AX focus instability.
let selected_text = {
// Up to 2 retries, 35ms apart.
read_selected_text_with_retries(2, 35)
};
match selected_text {
Some(text) if !text.is_empty() => {
empty_count = 0;
if text == stable_text {
stable_count += 1;
} else {
stable_text = text.clone();
stable_count = 1;
}
// Update/show only when selection is stable to avoid flicker.
if stable_count >= stable_threshold {
if !popup_visible || text != last_text {
// Second guard: do not emit when Coco is frontmost
// or the system-wide focused element belongs to Coco.
// Keep popup as-is to allow user interaction.
if is_frontmost_app_me() || is_focused_element_me() {
stable_count = 0;
empty_count = 0;
continue;
}
let (x, y) = current_mouse_point_global();
let payload = SelectionEventPayload {
text: text.clone(),
x,
y,
};
let _ = app_handle.emit("selection-detected", payload);
// Log selection state change once per stable update to avoid flooding.
let snippet: String = stable_text.chars().take(120).collect();
log::info!(target: "coco_lib::selection_monitor", "selection stable; showing popup (len={}, snippet=\"{}\")", stable_text.len(), snippet.replace('\n', "\\n"));
last_text = text;
popup_visible = true;
}
}
}
_ => {
// If not Coco in front and selection is empty: accumulate empties, then hide.
if !front_is_me {
stable_count = 0;
empty_count += 1;
if popup_visible && empty_count >= empty_threshold {
let _ = app_handle.emit("selection-detected", "");
log::info!(target: "coco_lib::selection_monitor", "selection empty; hiding popup");
popup_visible = false;
last_text.clear();
stable_text.clear();
}
} else {
// When Coco is frontmost: do not hide or clear state during interaction.
}
}
}
}
});
}
/// Ensure macOS Accessibility permission with double-checking and session throttling.
/// Returns true when trusted; otherwise triggers prompt/settings link and emits
/// `selection-permission-required` to the frontend, then returns false.
#[cfg(target_os = "macos")]
fn ensure_accessibility_permission(app_handle: &tauri::AppHandle) -> bool {
use std::time::{Duration, Instant};
log::debug!(target: "coco_lib::selection_monitor", "ensure_accessibility_permission: begin");
// First check — fast path.
let trusted = macos_accessibility_client::accessibility::application_is_trusted();
if trusted {
SEEN_ACCESSIBILITY_TRUSTED_ONCE.store(true, Ordering::Relaxed);
log::debug!(target: "coco_lib::selection_monitor", "ensure_accessibility_permission: trusted=true (fast path)");
return true;
}
// If we've seen trust earlier in this session, transient false may occur.
// Re-check after a short delay to avoid spurious prompts.
if SEEN_ACCESSIBILITY_TRUSTED_ONCE.load(Ordering::Relaxed) {
std::thread::sleep(Duration::from_millis(150));
if macos_accessibility_client::accessibility::application_is_trusted() {
log::debug!(target: "coco_lib::selection_monitor", "ensure_accessibility_permission: trusted=true (after transient recheck)");
return true;
}
}
// Throttle system prompt to at most once per 60s in this session.
let mut last = LAST_ACCESSIBILITY_PROMPT.lock().unwrap();
let now = Instant::now();
let allow_prompt = match *last {
Some(ts) => now.duration_since(ts) > Duration::from_secs(60),
None => true,
};
if allow_prompt {
// Try to trigger the system authorization prompt.
log::debug!(target: "coco_lib::selection_monitor", "ensure_accessibility_permission: triggering system prompt");
let _ = macos_accessibility_client::accessibility::application_is_trusted_with_prompt();
*last = Some(now);
// Small grace period then re-check.
std::thread::sleep(Duration::from_millis(150));
if macos_accessibility_client::accessibility::application_is_trusted() {
SEEN_ACCESSIBILITY_TRUSTED_ONCE.store(true, Ordering::Relaxed);
log::info!(target: "coco_lib::selection_monitor", "ensure_accessibility_permission: user granted accessibility during prompt");
return true;
}
log::warn!(target: "coco_lib::selection_monitor", "ensure_accessibility_permission: still not trusted after prompt");
}
// Still not trusted — notify frontend and deep-link to settings.
let _ = app_handle.emit("selection-permission-required", true);
log::debug!(target: "coco_lib::selection_monitor", "selection-permission-required emitted");
// Provide richer context so frontend can give more explicit guidance.
let info = collect_selection_permission_info();
log::info!(target: "coco_lib::selection_monitor", "selection-permission-info: bundle_id={}, exe_path={}, in_applications={}, is_dmg={}, is_dev_guess={}",
info.bundle_id, info.exe_path, info.in_applications, info.is_dmg, info.is_dev_guess);
let _ = app_handle.emit("selection-permission-info", info);
#[allow(unused_must_use)]
{
use std::process::Command;
Command::new("open")
.arg("x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility")
.status();
}
false
}
#[cfg(target_os = "macos")]
fn collect_selection_permission_info() -> SelectionPermissionInfo {
let exe_path = std::env::current_exe()
.map(|p| p.display().to_string())
.unwrap_or_else(|_| String::from("<unknown>"));
let in_applications = exe_path.starts_with("/Applications/");
let is_dmg = exe_path.starts_with("/Volumes/");
let is_dev_guess = exe_path.contains("/target/debug/")
|| exe_path.contains(".cargo")
|| exe_path.contains("/node_modules/");
// Find the nearest *.app directory from the current executable path.
let bundle_id = get_bundle_id_dynamic();
SelectionPermissionInfo {
bundle_id,
exe_path,
in_applications,
is_dmg,
is_dev_guess,
}
}
#[cfg(target_os = "macos")]
fn get_bundle_id_dynamic() -> String {
use std::path::PathBuf;
use std::process::Command;
// Find the nearest *.app directory from the current executable path.
let mut app_dir: Option<PathBuf> = None;
if let Ok(mut p) = std::env::current_exe() {
for _ in 0..8 {
if let Some(name) = p.file_name().and_then(|n| n.to_str()) {
if name.ends_with(".app") {
app_dir = Some(p.clone());
break;
}
}
if !p.pop() {
break;
}
}
}
if let Some(app) = app_dir {
let info = app.join("Contents").join("Info.plist");
if info.exists() {
// use `defaults read <Info.plist> CFBundleIdentifier` to read Bundle ID
if let Ok(out) = Command::new("defaults")
.arg("read")
.arg(info.to_string_lossy().into_owned())
.arg("CFBundleIdentifier")
.output()
{
if out.status.success() {
let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
if !s.is_empty() {
return s;
}
}
}
}
}
// Fallback: use the default bundle ID when in dev mode or Info.plist is not found.
"rs.coco.app".to_string()
}
// macOS-wide accessibility entry point: allows reading system-level focused elements.
#[cfg(target_os = "macos")]
unsafe extern "C" {
fn AXUIElementCreateSystemWide() -> *mut objc2_application_services::AXUIElement;
}
#[cfg(target_os = "macos")]
unsafe extern "C" {
fn AXUIElementGetPid(
element: *mut objc2_application_services::AXUIElement,
pid: *mut i32,
) -> objc2_application_services::AXError;
}
#[cfg(target_os = "macos")]
fn is_focused_element_me() -> bool {
use objc2_application_services::{AXError, AXUIElement};
use objc2_core_foundation::{CFRetained, CFString, CFType};
use std::ptr::NonNull;
let mut focused_ui_ptr: *const CFType = std::ptr::null();
let focused_attr = CFString::from_static_str("AXFocusedUIElement");
let system_elem = unsafe { AXUIElementCreateSystemWide() };
if system_elem.is_null() {
return false;
}
let system_elem_retained: CFRetained<AXUIElement> =
unsafe { CFRetained::from_raw(NonNull::new(system_elem).unwrap()) };
let err = unsafe {
system_elem_retained
.copy_attribute_value(&focused_attr, NonNull::new(&mut focused_ui_ptr).unwrap())
};
if err != AXError::Success || focused_ui_ptr.is_null() {
return false;
}
let focused_ui_elem: *mut AXUIElement = focused_ui_ptr.cast::<AXUIElement>().cast_mut();
let mut pid: i32 = -1;
let get_err = unsafe { AXUIElementGetPid(focused_ui_elem, &mut pid as *mut i32) };
if get_err != AXError::Success {
return false;
}
let my_pid = std::process::id() as i32;
pid == my_pid
}
/// Read the selected text of the frontmost application (without using the clipboard).
/// macOS only. Returns `None` when the frontmost app is Coco to avoid false empties.
#[cfg(target_os = "macos")]
fn read_selected_text() -> Option<String> {
use objc2_app_kit::NSWorkspace;
use objc2_application_services::{AXError, AXUIElement};
use objc2_core_foundation::{CFRetained, CFString, CFType};
use std::ptr::NonNull;
// Prefer system-wide focused element; if unavailable, fall back to app/window focused element.
let mut focused_ui_ptr: *const CFType = std::ptr::null();
let focused_attr = CFString::from_static_str("AXFocusedUIElement");
// System-wide focused UI element.
let system_elem = unsafe { AXUIElementCreateSystemWide() };
if !system_elem.is_null() {
let system_elem_retained: CFRetained<AXUIElement> =
unsafe { CFRetained::from_raw(NonNull::new(system_elem).unwrap()) };
let err = unsafe {
system_elem_retained
.copy_attribute_value(&focused_attr, NonNull::new(&mut focused_ui_ptr).unwrap())
};
if err != AXError::Success {
focused_ui_ptr = std::ptr::null();
}
}
// Fallback to the frontmost app's focused/window element.
if focused_ui_ptr.is_null() {
let workspace = unsafe { NSWorkspace::sharedWorkspace() };
let frontmost_app = unsafe { workspace.frontmostApplication() }?;
let pid = unsafe { frontmost_app.processIdentifier() };
// Skip if frontmost is Coco (this process).
let my_pid = std::process::id() as i32;
if pid == my_pid {
return None;
}
let app_element = unsafe { AXUIElement::new_application(pid) };
let err = unsafe {
app_element
.copy_attribute_value(&focused_attr, NonNull::new(&mut focused_ui_ptr).unwrap())
};
if err != AXError::Success || focused_ui_ptr.is_null() {
// Try `AXFocusedWindow` as a lightweight fallback.
let mut focused_window_ptr: *const CFType = std::ptr::null();
let focused_window_attr = CFString::from_static_str("AXFocusedWindow");
let w_err = unsafe {
app_element.copy_attribute_value(
&focused_window_attr,
NonNull::new(&mut focused_window_ptr).unwrap(),
)
};
if w_err != AXError::Success || focused_window_ptr.is_null() {
return None;
}
focused_ui_ptr = focused_window_ptr;
}
}
let focused_ui_elem: *mut AXUIElement = focused_ui_ptr.cast::<AXUIElement>().cast_mut();
let focused_ui: CFRetained<AXUIElement> =
unsafe { CFRetained::from_raw(NonNull::new(focused_ui_elem).unwrap()) };
// Prefer `AXSelectedText`; otherwise return None (can be extended to read ranges).
let mut selected_text_ptr: *const CFType = std::ptr::null();
let selected_text_attr = CFString::from_static_str("AXSelectedText");
let err = unsafe {
focused_ui.copy_attribute_value(
&selected_text_attr,
NonNull::new(&mut selected_text_ptr).unwrap(),
)
};
if err != AXError::Success || selected_text_ptr.is_null() {
return None;
}
// CFString → Rust String
let selected_cfstr: CFRetained<CFString> = unsafe {
CFRetained::from_raw(NonNull::new(selected_text_ptr.cast::<CFString>().cast_mut()).unwrap())
};
Some(selected_cfstr.to_string())
}
/// Read selected text with lightweight retries to handle transient AX focus instability.
#[cfg(target_os = "macos")]
fn read_selected_text_with_retries(retries: u32, delay_ms: u64) -> Option<String> {
use std::thread;
use std::time::Duration;
for attempt in 0..=retries {
if let Some(text) = read_selected_text() {
if !text.is_empty() {
if attempt > 0 {
// log::info!(
// "read_selected_text: 第{}次重试成功,获取到选中文本",
// attempt
// );
}
return Some(text);
}
}
if attempt < retries {
thread::sleep(Duration::from_millis(delay_ms));
}
}
None
}

View File

@@ -1,9 +1,14 @@
use super::servers::{get_server_by_id, get_server_token};
use crate::common::error::serialize_error;
use crate::common::http::get_response_body_text;
use crate::server::http_client::HttpClient;
use crate::server::http_client::{HttpClient, HttpRequestError, SendSnafu};
use reqwest::multipart::{Form, Part};
use serde::{Deserialize, Serialize};
use serde::Deserialize;
use serde::Serialize;
use serde_json::Value;
use snafu::prelude::*;
use std::ffi::OsString;
use std::io;
use std::{collections::HashMap, path::PathBuf};
use tauri::command;
use tokio::fs::File;
@@ -21,23 +26,66 @@ pub struct DeleteAttachmentResponse {
pub result: String,
}
#[derive(Debug, Snafu, Serialize)]
#[snafu(visibility(pub(crate)))]
pub(crate) enum AttachmentError {
#[snafu(display("attachment file '{}' does not exist", file.display()))]
FileNotFound { file: PathBuf },
#[snafu(display("I/O error"))]
Io {
#[serde(serialize_with = "serialize_error")]
source: io::Error,
},
#[snafu(display("attachment file '{}' does not have a name", file.display()))]
NoFilename { file: PathBuf },
#[snafu(display("attachment filename '{}' is not UTF-8 encoded", filename.display()))]
NonUtf8Filename { filename: OsString },
#[snafu(display("coco server with the specified ID filename '{}' does not exist", id))]
ServerNotFound { id: String },
#[snafu(display("HTTP request failed"))]
HttpRequestError { source: HttpRequestError },
#[snafu(display("decoding JSON failed"))]
JsonDecodingError {
#[serde(serialize_with = "serialize_error")]
source: serde_json::Error,
},
}
#[command]
pub async fn upload_attachment(
server_id: String,
file_paths: Vec<PathBuf>,
) -> Result<UploadAttachmentResponse, String> {
) -> Result<UploadAttachmentResponse, AttachmentError> {
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 file = match File::open(&file_path).await {
Ok(file) => file,
Err(io_err) => {
let io_err_kind = io_err.kind();
if io_err_kind == io::ErrorKind::NotFound {
return Err(AttachmentError::FileNotFound {
file: file_path.clone(),
});
} else {
return Err(AttachmentError::Io { source: io_err });
}
}
};
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 file_name_os_str =
file_path
.file_name()
.ok_or_else(|| AttachmentError::NoFilename {
file: file_path.clone(),
})?;
let file_name =
file_name_os_str
.to_str()
.ok_or_else(|| AttachmentError::NonUtf8Filename {
filename: file_name_os_str.to_os_string(),
})?;
let part =
Part::stream(reqwest::Body::wrap_stream(stream)).file_name(file_name.to_string());
@@ -45,9 +93,12 @@ pub async fn upload_attachment(
form = form.part("files", part);
}
let server = get_server_by_id(&server_id)
.await
.ok_or("Server not found")?;
let server =
get_server_by_id(&server_id)
.await
.ok_or_else(|| AttachmentError::ServerNotFound {
id: server_id.clone(),
})?;
let url = HttpClient::join_url(&server.endpoint, &format!("attachment/_upload"));
let token = get_server_token(&server_id).await;
@@ -60,22 +111,24 @@ pub async fn upload_attachment(
let response = client
.post(url)
.multipart(form)
.headers((&headers).try_into().map_err(|err| format!("{}", err))?)
.headers((&headers).try_into().expect("conversion should not fail"))
.send()
.await
.map_err(|err| err.to_string())?;
.context(SendSnafu)
.context(HttpRequestSnafu)?;
let body = get_response_body_text(response).await?;
let body = get_response_body_text(response)
.await
.context(HttpRequestSnafu)?;
serde_json::from_str::<UploadAttachmentResponse>(&body)
.map_err(|e| format!("Failed to parse upload response: {}", e))
serde_json::from_str::<UploadAttachmentResponse>(&body).context(JsonDecodingSnafu)
}
#[command]
pub async fn get_attachment_by_ids(
server_id: String,
attachments: Vec<String>,
) -> Result<Value, String> {
) -> Result<Value, AttachmentError> {
println!("get_attachment_by_ids server_id: {}", server_id);
println!("get_attachment_by_ids attachments: {:?}", attachments);
@@ -86,28 +139,27 @@ pub async fn get_attachment_by_ids(
let response = HttpClient::post(&server_id, "/attachment/_search", None, Some(body))
.await
.map_err(|e| format!("Request error: {}", e))?;
.context(HttpRequestSnafu)?;
let body = get_response_body_text(response).await?;
let body = get_response_body_text(response)
.await
.context(HttpRequestSnafu)?;
serde_json::from_str::<Value>(&body)
.map_err(|e| format!("Failed to parse attachment response: {}", e))
serde_json::from_str::<Value>(&body).context(JsonDecodingSnafu)
}
#[command]
pub async fn delete_attachment(server_id: String, id: String) -> Result<bool, String> {
pub async fn delete_attachment(server_id: String, id: String) -> Result<bool, AttachmentError> {
let response = HttpClient::delete(&server_id, &format!("/attachment/{}", id), None, None)
.await
.map_err(|e| format!("Request error: {}", e))?;
.context(HttpRequestSnafu)?;
let body = get_response_body_text(response).await?;
let body = get_response_body_text(response)
.await
.context(HttpRequestSnafu)?;
let parsed: DeleteAttachmentResponse = serde_json::from_str(&body)
.map_err(|e| format!("Failed to parse delete response: {}", e))?;
let parsed: DeleteAttachmentResponse =
serde_json::from_str(&body).context(JsonDecodingSnafu)?;
parsed
.result
.eq("deleted")
.then_some(true)
.ok_or_else(|| "Delete operation was not successful".to_string())
Ok(parsed.result.eq("deleted"))
}

View File

@@ -1,9 +1,13 @@
use crate::common::error::ApiError;
use crate::common::error::serialize_error;
use crate::server::servers::{get_server_by_id, get_server_token};
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::Serialize;
use snafu::prelude::*;
use std::collections::HashMap;
use std::sync::LazyLock;
use std::time::Duration;
@@ -29,6 +33,52 @@ pub static HTTP_CLIENT: Lazy<Mutex<Client>> = Lazy::new(|| {
Mutex::new(new_reqwest_http_client(allow_self_signature))
});
/// Errors that could happen when handling a HTTP request.
///
/// `reqwest` uses the same error type `reqwest::Error` for all kinds of
/// errors, it distinguishes kinds via those `is_xxx()` methods (e.g.,
/// `is_connect()` [1]). Due to this reason, both `SendError` and
/// `DecodeResponseError` use `request::Error` as the associated value.
///
/// Technically, `ServerNotFound` is not a HTTP request error, but Coco app
/// primarily send HTTP requests to Coco servers, so it is included.
///
/// [1]: https://docs.rs/reqwest/0.12.24/reqwest/struct.Error.html#method.is_connect
#[derive(Debug, Snafu, Serialize)]
#[snafu(visibility(pub(crate)))]
pub(crate) enum HttpRequestError {
#[snafu(display("failed to send HTTP request"))]
SendError {
#[serde(serialize_with = "serialize_error")]
source: reqwest::Error,
},
#[snafu(display("failed to decode HTTP response"))]
DecodeResponseError {
#[serde(serialize_with = "serialize_error")]
source: reqwest::Error,
},
#[snafu(display("connection timed out"))]
ConnectionTimeout,
#[snafu(display(
"HTTP request failed, status '{}', response body '{:?}', coco_server_api_error: '{:?}'",
status,
error_response_body_str,
coco_server_api_error_response_body,
))]
RequestFailed {
status: u16,
/// None if we do not have response body.
error_response_body_str: Option<String>,
/// Some if:
///
/// 1. This is a request sent to Coco server
/// 2. We successfully decode an `ApiError` from the `error_response_body_str`.
coco_server_api_error_response_body: Option<ApiError>,
},
#[snafu(display("no Coco server with specific ID '{}' exists", id))]
ServerNotFound { id: String },
}
/// These header values won't change during a process's lifetime.
static STATIC_HEADERS: LazyLock<HashMap<String, String>> = LazyLock::new(|| {
HashMap::from([
@@ -65,7 +115,7 @@ impl HttpClient {
query_params: Option<Vec<String>>,
headers: Option<HashMap<String, String>>,
body: Option<reqwest::Body>,
) -> Result<reqwest::Response, String> {
) -> Result<reqwest::Response, HttpRequestError> {
log::debug!(
"Sending Request: {}, query_params: {:?}, header: {:?}, body: {:?}",
&url,
@@ -77,10 +127,16 @@ impl HttpClient {
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)
})?;
let response = match request_builder.send().await {
Ok(response) => response,
Err(e) => {
if e.is_timeout() {
return Err(HttpRequestError::ConnectionTimeout);
} else {
return Err(HttpRequestError::SendError { source: e });
}
}
};
log::debug!(
"Request: {}, Response status: {:?}, header: {:?}",
@@ -173,7 +229,7 @@ impl HttpClient {
custom_headers: Option<HashMap<String, String>>,
query_params: Option<Vec<String>>,
body: Option<reqwest::Body>,
) -> Result<reqwest::Response, String> {
) -> Result<reqwest::Response, HttpRequestError> {
// Fetch the server using the server_id
let server = get_server_by_id(server_id).await;
if let Some(s) = server {
@@ -205,7 +261,9 @@ impl HttpClient {
Self::send_raw_request(method, &url, query_params, Some(headers), body).await
} else {
Err(format!("Server [{}] not found", server_id))
Err(HttpRequestError::ServerNotFound {
id: server_id.to_string(),
})
}
}
@@ -214,7 +272,7 @@ impl HttpClient {
server_id: &str,
path: &str,
query_params: Option<Vec<String>>,
) -> Result<reqwest::Response, String> {
) -> Result<reqwest::Response, HttpRequestError> {
HttpClient::send_request(server_id, Method::GET, path, None, query_params, None).await
}
@@ -224,7 +282,7 @@ impl HttpClient {
path: &str,
query_params: Option<Vec<String>>,
body: Option<reqwest::Body>,
) -> Result<reqwest::Response, String> {
) -> Result<reqwest::Response, HttpRequestError> {
HttpClient::send_request(server_id, Method::POST, path, None, query_params, body).await
}
@@ -234,7 +292,7 @@ impl HttpClient {
custom_headers: Option<HashMap<String, String>>,
query_params: Option<Vec<String>>,
body: Option<reqwest::Body>,
) -> Result<reqwest::Response, String> {
) -> Result<reqwest::Response, HttpRequestError> {
HttpClient::send_request(
server_id,
Method::POST,
@@ -254,7 +312,7 @@ impl HttpClient {
custom_headers: Option<HashMap<String, String>>,
query_params: Option<Vec<String>>,
body: Option<reqwest::Body>,
) -> Result<reqwest::Response, String> {
) -> Result<reqwest::Response, HttpRequestError> {
HttpClient::send_request(
server_id,
Method::PUT,
@@ -273,7 +331,7 @@ impl HttpClient {
path: &str,
custom_headers: Option<HashMap<String, String>>,
query_params: Option<Vec<String>>,
) -> Result<reqwest::Response, String> {
) -> Result<reqwest::Response, HttpRequestError> {
HttpClient::send_request(
server_id,
Method::DELETE,

View File

@@ -1,13 +1,14 @@
use crate::common::document::{Document, OnOpened};
use crate::common::error::SearchError;
use crate::common::error::{HttpSnafu, ResponseDecodeSnafu, 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::SearchSource;
use crate::server::http_client::HttpClient;
use crate::server::http_client::{HttpClient, HttpRequestError};
use async_trait::async_trait;
use ordered_float::OrderedFloat;
use reqwest::StatusCode;
use snafu::ResultExt;
use std::collections::HashMap;
use tauri::AppHandle;
@@ -112,31 +113,29 @@ impl SearchSource for CocoSearchSource {
let response = HttpClient::get(&self.server.id, &url, Some(query_params))
.await
.map_err(|e| SearchError::HttpError {
status_code: None,
msg: format!("{}", e),
})?;
.context(HttpSnafu)?;
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),
});
let http_err = HttpRequestError::RequestFailed {
status: status_code.as_u16(),
error_response_body_str: None,
coco_server_api_error_response_body: None,
};
let search_err = SearchError::HttpError { source: http_err };
return Err(search_err);
}
// Use the helper function to parse the response body
let response_body = get_response_body_text(response)
.await
.map_err(|e| SearchError::ParseError(e))?;
let response_body = get_response_body_text(response).await.context(HttpSnafu)?;
// 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)))?;
let parsed: SearchResponse<Document> =
serde_json::from_str(&response_body).context(ResponseDecodeSnafu)?;
// Process the parsed response
total_hits = parsed.hits.total.value as usize;

View File

@@ -1,4 +1,5 @@
use crate::COCO_TAURI_STORE;
use crate::common::error::{ReportErrorStyle, report_error};
use crate::common::http::get_response_body_text;
use crate::common::register::SearchSourceRegistry;
use crate::common::server::{AuthProvider, Provider, Server, ServerAccessToken, Sso, Version};
@@ -13,8 +14,11 @@ use serde_json::Value as JsonValue;
use serde_json::from_value;
use std::collections::HashMap;
use std::sync::LazyLock;
use std::thread;
use std::time::Duration;
use tauri::{AppHandle, Manager};
use tauri_plugin_store::StoreExt;
use tokio::runtime;
use tokio::sync::RwLock;
/// Coco sever list
@@ -312,6 +316,109 @@ pub async fn refresh_all_coco_server_info(app_handle: AppHandle) {
}
}
/// Start a background worker that periodically sends heartbeats (`GET /provider/_info`)
/// to the connected Coco servers, checks if they are available and updates the
/// `SearchSourceRegistry` accordingly.
pub(crate) fn start_bg_heartbeat_worker(tauri_app_handle: AppHandle) {
const THREAD_NAME: &str = "Coco background heartbeat worker";
const SLEEP_DURATION: Duration = Duration::from_secs(15);
let main_closure = || {
let single_thread_rt = runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap_or_else(|e| {
panic!(
"failed to create a single-threaded Tokio runtime within thread [{}] because [{}]",
THREAD_NAME, e
);
});
single_thread_rt.block_on(async move {
let mut server_removed = Vec::new();
let mut server_added = Vec::new();
let search_sources = tauri_app_handle.state::<SearchSourceRegistry>();
loop {
log::info!("Coco Server Heartbeat worker is working...");
refresh_all_coco_server_info(tauri_app_handle.clone()).await;
/*
* For the Coco servers that are included in the SearchSourceRegistry
* but unavailable, they should be removed from the registry.
*
* We do this step first so that there are less search source to
* scan.
*/
for search_source in search_sources.get_sources().await {
let query_source = search_source.get_type();
let search_source_id = query_source.id;
let search_source_name = query_source.name;
let Some(coco_server) = get_server_by_id(&search_source_id).await else {
// This search source may not be a Coco server, try the next one.
continue;
};
assert!(
coco_server.enabled,
"Coco servers stored in search source list should all be enabled"
);
if !coco_server.available {
let removed = search_sources.remove_source(&search_source_id).await;
if removed {
server_removed.push((search_source_id, search_source_name));
}
}
}
/*
* Coco servers that are available and enabled should be added to
* the SearchSourceRegistry if they are not already included.
*/
for coco_server in get_all_servers().await {
if coco_server.enabled
&& coco_server.available
&& search_sources.get_source(&coco_server.id).await.is_none()
{
server_added.push((coco_server.id.clone(), coco_server.name.clone()));
let source = CocoSearchSource::new(coco_server);
search_sources.register_source(source).await;
}
}
/*
* Log the updates to SearchSourceRegistry
*/
log::info!(
"Coco Server Heartbeat worker: removed {:?} from the SearchSourceRegistry",
server_removed
);
log::info!(
"Coco Server Heartbeat worker: added {:?} to the SearchSourceRegistry",
server_added
);
// Sleep for a period of time
tokio::time::sleep(SLEEP_DURATION).await;
}
});
};
thread::Builder::new()
.name(THREAD_NAME.into())
.spawn(main_closure)
.unwrap_or_else(|e| {
panic!(
"failed to start thread [{}] for reason [{}]",
THREAD_NAME, e
)
});
}
#[tauri::command]
pub async fn refresh_coco_server_info(app_handle: AppHandle, id: String) -> Result<Server, String> {
// Retrieve the server from the cache
@@ -335,7 +442,7 @@ pub async fn refresh_coco_server_info(app_handle: AppHandle, id: String) -> Resu
Ok(response) => response,
Err(e) => {
mark_server_as_offline(app_handle, &id).await;
return Err(e);
return Err(report_error(&e, ReportErrorStyle::SingleLine));
}
};
@@ -345,7 +452,9 @@ pub async fn refresh_coco_server_info(app_handle: AppHandle, id: String) -> Resu
}
// Get body text via helper
let body = get_response_body_text(response).await?;
let body = get_response_body_text(response)
.await
.map_err(|e| report_error(&e, ReportErrorStyle::SingleLine))?;
// Deserialize server
let mut updated_server: Server = serde_json::from_str(&body)
@@ -414,7 +523,9 @@ pub async fn add_coco_server(app_handle: AppHandle, endpoint: String) -> Result<
return Err("This Coco server is possibly down".into());
}
let body = get_response_body_text(response).await?;
let body = get_response_body_text(response)
.await
.map_err(|e| report_error(&e, ReportErrorStyle::SingleLine))?;
let mut server: Server = serde_json::from_str(&body)
.map_err(|e| format!("Failed to deserialize the response: {}", e))?;

View File

@@ -1,4 +1,4 @@
use crate::server::http_client::HttpClient;
use crate::server::http_client::{HttpClient, HttpRequestError};
use futures_util::StreamExt;
use http::Method;
use serde_json::json;
@@ -11,7 +11,7 @@ pub async fn synthesize(
server_id: String,
voice: String,
content: String,
) -> Result<(), String> {
) -> Result<(), HttpRequestError> {
let body = json!({
"voice": voice,
"content": content,
@@ -30,12 +30,18 @@ pub async fn synthesize(
log::info!("Synthesize response status: {}", response.status());
if response.status() == 429 {
let status_code = response.status();
if status_code == 429 {
return Ok(());
}
if !response.status().is_success() {
return Err(format!("Request Failed: {}", response.status()));
if !status_code.is_success() {
return Err(HttpRequestError::RequestFailed {
status: status_code.as_u16(),
error_response_body_str: None,
coco_server_api_error_response_body: None,
});
}
let mut stream = response.bytes_stream();

View File

@@ -82,6 +82,14 @@ pub(crate) async fn backend_setup(tauri_app_handle: AppHandle, app_lang: String)
.expect("global tauri AppHandle already initialized");
log::trace!("global Tauri AppHandle set");
/*
* Initialize the app language setting stored in backend.
*
* This should be set before Rust code makes any HTTP requests as it is
* needed to provider HTTP header: X-APP-LANG
*/
update_app_lang(app_lang).await;
let registry = SearchSourceRegistry::default();
tauri_app_handle.manage(registry); // Store registry in Tauri's app state
@@ -99,6 +107,12 @@ pub(crate) async fn backend_setup(tauri_app_handle: AppHandle, app_lang: String)
})
.expect("failed to run this closure on the main thread");
// Start system-wide selection monitor (macOS-only currently)
#[cfg(target_os = "macos")]
{
crate::selection_monitor::start_selection_monitor(tauri_app_handle.clone());
}
crate::init(&tauri_app_handle).await;
if let Err(err) = crate::extension::init_extensions(&tauri_app_handle).await {
@@ -110,8 +124,6 @@ pub(crate) async fn backend_setup(tauri_app_handle: AppHandle, app_lang: String)
autostart::ensure_autostart_state_consistent(&tauri_app_handle).unwrap();
update_app_lang(app_lang).await;
// Invoked, now update the state
BACKEND_SETUP_COMPLETED.store(true, Ordering::Relaxed);
}

View File

@@ -58,5 +58,5 @@ pub(crate) async fn update_app_lang(lang: String) {
/// 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")
opt_lang.expect("frontend code did not invoke [backend_setup()] to set the APP_LANG")
}

View File

@@ -1,4 +1,9 @@
use crate::common::error::serialize_error;
use crate::common::error::{ReportErrorStyle, report_error};
use semver::{BuildMetadata, Prerelease, Version as SemVer};
use serde::Serialize;
use snafu::{ResultExt, prelude::*};
use std::num::ParseIntError;
use std::sync::LazyLock;
use tauri_plugin_updater::RemoteRelease;
@@ -12,6 +17,22 @@ pub(crate) static COCO_VERSION: LazyLock<SemVer> = LazyLock::new(|| {
parse_coco_semver(env!("CARGO_PKG_VERSION")).expect("parsing should never fail, if version format changes, then parse_coco_semver() should be updated as well")
});
#[derive(Debug, Snafu, Serialize)]
#[snafu(visibility(pub(crate)))]
pub(crate) enum ParseVersionError {
#[snafu(display("SemVer::Version::parse() failed"))]
SemVerParseError {
#[serde(serialize_with = "serialize_error")]
source: semver::Error,
},
#[snafu(display("failed to parse build number '{}'", build_number))]
ParseBuildNumberError {
build_number: String,
#[serde(serialize_with = "serialize_error")]
source: ParseIntError,
},
}
/// Coco AI app adopt SemVer but the version string format does not adhere to
/// the SemVer specification, this function does the conversion. Returns `None`
/// if the input is not in the expected format so that the conversion cannot
@@ -30,11 +51,11 @@ pub(crate) static COCO_VERSION: LazyLock<SemVer> = LazyLock::new(|| {
/// * 0.9.0-SNAPSHOT-<build num> => 0.9.0-SNAPSHOT.<build num>
///
/// A pre-release of 0.9.0
fn to_semver(version: &SemVer) -> Option<SemVer> {
fn to_semver(version: &SemVer) -> Result<SemVer, ParseVersionError> {
let pre = &version.pre;
if pre.is_empty() {
return Some(SemVer::new(version.major, version.minor, version.patch));
return Ok(SemVer::new(version.major, version.minor, version.patch));
}
let is_pre_release = pre.starts_with(SNAPSHOT_DASH);
@@ -44,19 +65,23 @@ fn to_semver(version: &SemVer) -> Option<SemVer> {
pre.as_str()
};
// Parse the build number to validate it, we do not need the actual number though.
build_number_str.parse::<usize>().ok()?;
build_number_str
.parse::<usize>()
.context(ParseBuildNumberSnafu {
build_number: build_number_str.to_string(),
})?;
// Return after checking the build number is valid
if !is_pre_release {
return Some(SemVer::new(version.major, version.minor, version.patch));
return Ok(SemVer::new(version.major, version.minor, version.patch));
}
let pre = {
let pre_str = format!("{}.{}", SNAPSHOT, build_number_str);
Prerelease::new(&pre_str).unwrap_or_else(|e| panic!("invalid Prerelease: {}", e))
Prerelease::new(&pre_str).context(SemVerParseSnafu)?
};
Some(SemVer {
Ok(SemVer {
major: version.major,
minor: version.minor,
patch: version.patch,
@@ -67,15 +92,39 @@ fn to_semver(version: &SemVer) -> Option<SemVer> {
/// Parse Coco version string to a `SemVer`. Returns `None` if it is not a valid
/// version string.
pub(crate) fn parse_coco_semver(version_str: &str) -> Option<SemVer> {
let not_semver = SemVer::parse(version_str).ok()?;
pub(crate) fn parse_coco_semver(version_str: &str) -> Result<SemVer, ParseVersionError> {
let not_semver = SemVer::parse(version_str).context(SemVerParseSnafu)?;
to_semver(&not_semver)
}
pub(crate) fn custom_version_comparator(local: SemVer, remote_release: RemoteRelease) -> bool {
/// We are not allowed to populate errors in this function, so when errors
/// happen, we do not update.
const SHOULD_NOT_UPDATE_WHEN_UNEXPECTED_ERROR_HAPPEN: bool = false;
let remote = remote_release.version;
let local_semver = to_semver(&local);
let remote_semver = to_semver(&remote);
let local_semver = match to_semver(&local) {
Ok(ver) => ver,
Err(e) => {
log::error!(
"failed to parse this Coco app's version '{}', error {}",
local,
snafu::Report::from_error(e)
);
return SHOULD_NOT_UPDATE_WHEN_UNEXPECTED_ERROR_HAPPEN;
}
};
let remote_semver = match to_semver(&remote) {
Ok(ver) => ver,
Err(e) => {
log::error!(
"failed to parse the version '{}' fetch from the '.latest.json' file, error '{}'",
remote,
report_error(&e, ReportErrorStyle::SingleLine)
);
return SHOULD_NOT_UPDATE_WHEN_UNEXPECTED_ERROR_HAPPEN;
}
};
let should_update = remote_semver > local_semver;
@@ -172,14 +221,28 @@ mod tests {
fn test_try_into_semver_invalid_build_number() {
// Should panic when build number is not a valid number
let input = SemVer::parse("0.8.0-abc").unwrap();
assert!(to_semver(&input).is_none());
let err = to_semver(&input).unwrap_err();
assert!(matches!(
err,
ParseVersionError::ParseBuildNumberError {
build_number: _,
source: _
}
));
}
#[test]
fn test_try_into_semver_invalid_snapshot_build_number() {
// Should panic when SNAPSHOT build number is not a valid number
let input = SemVer::parse("0.9.0-SNAPSHOT-xyz").unwrap();
assert!(to_semver(&input).is_none());
let err = to_semver(&input).unwrap_err();
assert!(matches!(
err,
ParseVersionError::ParseBuildNumberError {
build_number: _,
source: _
}
));
}
#[test]

View File

@@ -5,7 +5,7 @@
"identifier": "rs.coco.app",
"build": {
"beforeDevCommand": "pnpm dev",
"devUrl": "http://localhost:1420",
"devUrl": "http://localhost:6060",
"beforeBuildCommand": "pnpm build",
"frontendDist": "../dist"
},
@@ -78,6 +78,29 @@
"state": "active",
"radius": 7
}
},
{
"label": "selection",
"title": "Selection",
"alwaysOnTop": true,
"shadow": false,
"decorations": false,
"transparent": true,
"closable": true,
"minimizable": false,
"maximizable": false,
"dragDropEnabled": false,
"resizable": false,
"center": false,
"url": "/ui/selection",
"hiddenTitle": true,
"visible": false,
"acceptFirstMouse": true,
"windowEffects": {
"effects": [],
"state": "active",
"radius": 7
}
}
],
"security": {
@@ -146,4 +169,4 @@
},
"os": {}
}
}
}

View File

@@ -25,6 +25,7 @@ export const AssistantFetcher = ({
query?: string;
}) => {
try {
// Only gate by current window service.
if (await unrequitable()) {
return {
total: 0,
@@ -66,7 +67,7 @@ export const AssistantFetcher = ({
lastServerId.current = currentService?.id;
return {
total: response.hits.total.value,
total: response?.hits?.total?.value ?? 0,
list: assistantList,
};
} catch (error) {

View File

@@ -47,6 +47,10 @@ export function AssistantList({ assistantIDs = [] }: AssistantListProps) {
const setAskAiAssistantId = useSearchStore((state) => {
return state.setAskAiAssistantId;
});
const targetAssistantId = useSearchStore((state) => state.targetAssistantId);
const setTargetAssistantId = useSearchStore((state) => {
return state.setTargetAssistantId;
});
const { fetchAssistant } = AssistantFetcher({
debounceKeyword,
@@ -81,17 +85,22 @@ export function AssistantList({ assistantIDs = [] }: AssistantListProps) {
const [isKeyboardActive, setIsKeyboardActive] = useState(false);
useEffect(() => {
if (!askAiAssistantId || assistantList.length === 0) return;
const matched = assistantList.find((item) => {
return item._id === askAiAssistantId;
});
const targetId = askAiAssistantId ?? targetAssistantId;
if (!targetId || assistantList.length === 0) return;
const matched = assistantList.find((item) => item._id === targetId);
if (!matched) return;
setCurrentAssistant(matched);
setAskAiAssistantId(void 0);
}, [assistantList, askAiAssistantId]);
if (currentAssistant?._id !== matched._id) {
setCurrentAssistant(matched);
}
if (askAiAssistantId) {
setAskAiAssistantId(void 0);
} else if (targetAssistantId) {
setTargetAssistantId(void 0);
}
}, [assistantList, askAiAssistantId, targetAssistantId]);
useKeyPress(
["uparrow", "downarrow", "enter"],

View File

@@ -46,6 +46,12 @@ export function ServerList({ clearChat }: ServerListProps) {
const [isRefreshing, setIsRefreshing] = useState(false);
const [highlightId, setHighlightId] = useState<string>("");
const targetServerId = useSearchStore((state) => {
return state.targetServerId;
});
const setTargetServerId = useSearchStore((state) => {
return state.setTargetServerId;
});
const askAiServerId = useSearchStore((state) => {
return state.askAiServerId;
});
@@ -102,17 +108,20 @@ export function ServerList({ clearChat }: ServerListProps) {
}, [serverList]);
useEffect(() => {
if (!askAiServerId || serverList.length === 0) return;
const matched = serverList.find((server) => {
return server.id === askAiServerId;
});
const targetId = targetServerId ?? askAiServerId;
if (!targetId || list.length === 0) return;
const matched = list.find((server) => server.id === targetId);
if (!matched) return;
switchServer(matched);
setAskAiServerId(void 0);
}, [serverList, askAiServerId]);
setHighlightId(matched.id);
if (targetServerId) {
setTargetServerId(void 0);
} else {
setAskAiServerId(void 0);
}
}, [list, askAiServerId, targetServerId]);
useEffect(() => {
if (!isTauri) return;
@@ -291,4 +300,4 @@ export function ServerList({ clearChat }: ServerListProps) {
</PopoverPanel>
</Popover>
);
}
}

View File

@@ -35,45 +35,53 @@ const ErrorNotification = ({
if (errors.length === 0 || suppressErrors) return null;
// Only show the latest error to avoid overwhelming the user
const visibleError = errors[errors.length - 1];
const remainingCount = Math.max(0, errors.length - 1);
return (
<div
className={`${
isTauri ? "fixed" : "absolute"
} bottom-10 right-4 z-50 max-w-[calc(100%-32px)] space-y-2`}
>
{errors.map((error) => (
<div
key={error.id}
className={`flex justify-between gap-4 items-center p-4 rounded-lg shadow-lg ${
error.type === "error"
? "bg-red-50 dark:bg-red-900"
: error.type === "warning"
? "bg-yellow-50 dark:bg-yellow-900"
: "bg-blue-50 dark:bg-blue-900"
}`}
>
<div className="flex">
{error.type === "error" && (
<AlertCircle className="w-5 h-5 text-red-500 mr-2" />
)}
{error.type === "warning" && (
<AlertTriangle className="w-5 h-5 text-yellow-500 mr-2" />
)}
{error.type === "info" && (
<Info className="w-5 h-5 text-blue-500 mr-2" />
)}
<div
key={visibleError.id}
className={`flex justify-between gap-4 items-center p-4 rounded-lg shadow-lg ${
visibleError.type === "error"
? "bg-red-50 dark:bg-red-900"
: visibleError.type === "warning"
? "bg-yellow-50 dark:bg-yellow-900"
: "bg-blue-50 dark:bg-blue-900"
}`}
>
<div className="flex items-center">
{visibleError.type === "error" && (
<AlertCircle className="w-5 h-5 text-red-500 mr-2" />
)}
{visibleError.type === "warning" && (
<AlertTriangle className="w-5 h-5 text-yellow-500 mr-2" />
)}
{visibleError.type === "info" && (
<Info className="w-5 h-5 text-blue-500 mr-2" />
)}
<span className="text-sm text-gray-700 dark:text-gray-200">
{error.message}
<span className="text-sm text-gray-700 dark:text-gray-200">
{visibleError.message}
</span>
{remainingCount > 0 && (
<span className="ml-2 px-2 py-1 text-xs rounded-md bg-black/5 dark:bg-white/10 text-gray-600 dark:text-gray-300">
+{remainingCount}
</span>
</div>
<X
className="w-5 h-5 ml-4 cursor-pointer text-gray-400 hover:text-gray-600"
onClick={() => removeError(error.id)}
/>
)}
</div>
))}
<X
className="w-5 h-5 ml-4 cursor-pointer text-gray-400 hover:text-gray-600"
onClick={() => removeError(visibleError.id)}
/>
</div>
</div>
);
};

View File

@@ -115,7 +115,7 @@ const AutoResizeTextarea = forwardRef<
autoComplete="off"
autoCapitalize="none"
spellCheck="false"
className="text-base flex-1 outline-none w-full min-w-[200px] text-[#333] dark:text-[#d8d8d8] placeholder-text-xs placeholder-[#999] dark:placeholder-gray-500 bg-transparent custom-scrollbar resize-none overflow-y-auto"
className="auto-resize-textarea text-base flex-1 outline-none w-full min-w-[200px] text-[#333] dark:text-[#d8d8d8] placeholder-text-xs placeholder-[#999] dark:placeholder-gray-500 bg-transparent custom-scrollbar resize-none overflow-y-auto"
placeholder={chatPlaceholder || t("search.textarea.placeholder")}
aria-label={t("search.textarea.ariaLabel")}
value={input}

View File

@@ -10,6 +10,7 @@ import {
} from "react";
import clsx from "clsx";
import { useMount, useMutationObserver } from "ahooks";
import { debounce } from "lodash-es";
import Search from "@/components/Search/Search";
import InputBox from "@/components/Search/InputBox";
@@ -34,9 +35,9 @@ import {
visibleSearchBar,
} from "@/utils";
import { useTauriFocus } from "@/hooks/useTauriFocus";
import { POPOVER_PANEL_SELECTOR } from "@/constants";
import { POPOVER_PANEL_SELECTOR, WINDOW_CENTER_BASELINE_HEIGHT } from "@/constants";
import { useChatStore } from "@/stores/chatStore";
import { debounce } from "lodash-es";
import { useSearchStore } from "@/stores/searchStore";
interface SearchChatProps {
isTauri?: boolean;
@@ -111,7 +112,7 @@ function SearchChat({
}
const width = 680;
let height = 590;
let height = WINDOW_CENTER_BASELINE_HEIGHT;
const updateAppDialog = document.querySelector("#update-app-dialog");
const popoverPanelEl = document.querySelector(POPOVER_PANEL_SELECTOR);
@@ -135,15 +136,19 @@ function SearchChat({
}
}
if (height < 590) {
if (height < WINDOW_CENTER_BASELINE_HEIGHT) {
const { compactModeAutoCollapseDelay } = useConnectStore.getState();
console.log("compactModeAutoCollapseDelay", compactModeAutoCollapseDelay);
collapseWindowTimer.current = setTimeout(() => {
setHideMiddleBorder(true);
setSuppressErrors(true);
const textarea = document.querySelector(".auto-resize-textarea");
if (textarea instanceof HTMLTextAreaElement) {
textarea.focus();
}
platformAdapter.setWindowSize(width, height);
}, compactModeAutoCollapseDelay * 1000);
} else {
@@ -310,6 +315,58 @@ function SearchChat({
const { normalOpacity, blurOpacity } = useAppearanceStore();
useEffect(() => {
const unlistenAsk = platformAdapter.listenEvent(
"selection-ask-ai",
({ payload }: any) => {
const value =
typeof payload === "string" ? payload : String(payload?.text ?? "");
dispatch({ type: "SET_CHAT_MODE", payload: true });
dispatch({ type: "SET_INPUT", payload: value });
platformAdapter.showWindow();
}
);
const unlistenAction = platformAdapter.listenEvent(
"selection-action",
({ payload }: any) => {
const { action, text, assistantId, serverId } = payload || {};
const value = String(text ?? "");
//
if (action === "search") {
dispatch({ type: "SET_CHAT_MODE", payload: false });
dispatch({ type: "SET_INPUT", payload: value });
const { setSearchValue } = useSearchStore.getState();
setSearchValue(value);
} else if (action === "chat") {
dispatch({ type: "SET_CHAT_MODE", payload: true });
dispatch({ type: "SET_INPUT", payload: value });
//
const { setTargetServerId, setTargetAssistantId } =
useSearchStore.getState();
if (serverId) {
setTargetServerId(serverId);
}
const { assistantList } = useConnectStore.getState();
const assistant = assistantList.find(
(item) => item._source?.id === assistantId
);
if (assistant) {
setTargetAssistantId(assistant._id);
}
}
}
);
return () => {
unlistenAsk.then((fn) => fn());
unlistenAction.then((fn) => fn());
};
}, []);
useEffect(() => {
if (isTauri) {
changeMode(defaultStartupWindow === "chatMode");

View File

@@ -0,0 +1,58 @@
import { Separator } from "@radix-ui/react-separator";
import cocoLogoImg from "@/assets/app-icon.png";
import SelectionToolbar from "@/components/Selection/Toolbar";
import type { ActionConfig, ButtonConfig } from "@/components/Settings/Advanced/components/Selection/config";
export default function HeaderToolbar({
buttons,
iconsOnly,
onAction,
onLogoClick,
className,
rootRef,
children,
}: {
buttons: ButtonConfig[];
iconsOnly: boolean;
onAction: (action: ActionConfig) => void;
onLogoClick?: () => void;
className?: string;
rootRef?: React.Ref<HTMLDivElement>;
children?: React.ReactNode;
}) {
return (
<div
ref={rootRef}
data-tauri-drag-region="false"
className={`flex items-center gap-1 px-2 py-1 flex-nowrap overflow-hidden ${className ?? ""}`}
>
<img
src={cocoLogoImg}
alt="Coco Logo"
className="w-6 h-6"
onClick={onLogoClick}
onError={(e) => {
try {
(e.target as HTMLImageElement).src = "/src-tauri/assets/logo.png";
} catch {}
}}
/>
<Separator
orientation="vertical"
decorative
className="mx-1 h-4 w-px bg-gray-300 dark:bg-white/30 shrink-0"
/>
<SelectionToolbar
buttons={buttons}
iconsOnly={iconsOnly}
onAction={onAction}
requireAssistantCheck={false}
/>
{children}
</div>
);
}

View File

@@ -0,0 +1,116 @@
import clsx from "clsx";
import { useTranslation } from "react-i18next";
import { Search } from "lucide-react";
import {
ActionConfig,
ButtonConfig,
IconConfig,
resolveLucideIcon,
} from "@/components/Settings/Advanced/components/Selection/config";
const requiresAssistant = (type?: string) =>
type === "ask_ai" || type === "translate" || type === "summary";
function IconRenderer({ icon }: { icon?: IconConfig }) {
if (icon?.type === "lucide") {
const Comp = resolveLucideIcon(icon?.name);
if (Comp) {
return (
<Comp
className="size-4 transition-transform duration-150"
// style={icon?.color ? { color: icon.color } : undefined}
/>
);
}
return (
<Search
className="size-4 transition-transform duration-150"
// style={icon?.color ? { color: icon.color } : undefined}
/>
);
}
if (icon?.type === "custom" && icon?.dataUrl) {
return (
<img
src={icon.dataUrl}
className="size-4 rounded"
alt=""
// style={
// icon?.color
// ? { filter: `drop-shadow(0 0 0 ${icon.color})` }
// : undefined
// }
/>
);
}
return <Search className="size-4 text-[#6366F1]" />;
}
function ToolbarButton({
btn,
onClick,
showLabel,
}: {
btn: ButtonConfig;
onClick: () => void;
showLabel: boolean;
}) {
const { t } = useTranslation();
const label = btn?.labelKey ? t(btn.labelKey) : btn?.label || btn?.id || "";
return (
<button
className="flex items-center gap-1 p-1 rounded-md cursor-pointer whitespace-nowrap transition-all duration-150"
onClick={onClick}
title={label}
>
<IconRenderer icon={btn?.icon} />
{showLabel && (
<span className="text-[12px] transition-opacity duration-150">
{label}
</span>
)}
</button>
);
}
export default function SelectionToolbar({
buttons,
iconsOnly,
onAction,
className,
requireAssistantCheck = true,
}: {
buttons: ButtonConfig[];
iconsOnly: boolean;
onAction: (action: ActionConfig) => void;
className?: string;
requireAssistantCheck?: boolean;
}) {
const visibleButtons = (Array.isArray(buttons) ? buttons : []).filter((btn: any) => {
if (!requireAssistantCheck) return true;
const type = btn?.action?.type;
if (requiresAssistant(type)) {
return Boolean(btn?.action?.assistantId);
}
return true;
});
return (
<div
className={clsx(
"flex items-center gap-1 flex-nowrap overflow-hidden",
className
)}
>
{visibleButtons.map((btn) => (
<ToolbarButton
key={btn.id}
btn={btn}
onClick={() => onAction(btn.action)}
showLabel={!iconsOnly}
/>
))}
</div>
);
}

View File

@@ -0,0 +1,37 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Plus } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ButtonConfig } from "./config";
import AddChatDialog from "./AddChatDialog";
interface AddChatButtonProps {
serverList: any[];
onAdd: (btn: ButtonConfig) => void;
}
export function AddChatButton({ serverList, onAdd }: AddChatButtonProps) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
return (
<div className="pt-1">
<Button
variant="ghost"
className="inline-flex items-center gap-2 border border-dashed border-border hover:border-primary/50 hover:bg-secondary/50 text-muted-foreground transition-all duration-200"
onClick={() => setOpen(true)}
>
<Plus className="w-4 h-4" />
{t("selection.actions.addChat")}
</Button>
<AddChatDialog
serverList={serverList}
open={open}
onOpenChange={setOpen}
onAdd={onAdd}
/>
</div>
);
}

View File

@@ -0,0 +1,224 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { IconPicker } from "@infinilabs/custom-icons";
import type { IconConfig } from "@infinilabs/custom-icons";
import { nanoid } from "nanoid";
import { AssistantFetcher } from "@/components/Assistant/AssistantFetcher";
import { Button } from "@/components/ui/button";
import { ButtonConfig } from "./config";
import { useThemeStore } from "@/stores/themeStore";
import { useAppStore } from "@/stores/appStore";
export default function AddChatDialog({
serverList,
open,
onOpenChange,
onAdd,
}: {
serverList: any[];
open: boolean;
onOpenChange: (v: boolean) => void;
onAdd: (btn: ButtonConfig) => void;
}) {
const { t, i18n } = useTranslation();
const { fetchAssistant } = AssistantFetcher({});
const [label, setLabel] = useState("");
const [iconType, setIconType] = useState<IconConfig["type"]>("lucide");
const [lucideName, setLucideName] = useState<string>("Bot");
const [color, setColor] = useState<string>("#0287FF");
const [dataUrl, setDataUrl] = useState<string>("");
const [serverId, setServerId] = useState<string>("");
const [assistantList, setAssistantList] = useState<any[]>([]);
const [assistantId, setAssistantId] = useState<string>("");
const [loading, setLoading] = useState<boolean>(false);
const activeTheme = useThemeStore((state) => state.activeTheme);
const language = useAppStore((state) => state.language);
useEffect(() => {
if (!serverId) {
setAssistantList([]);
return;
}
setLoading(true);
fetchAssistant({ current: 1, pageSize: 1000, serverId })
.then((data) => setAssistantList(data.list || []))
.catch(() => setAssistantList([]))
.finally(() => setLoading(false));
}, [serverId]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape" && open) {
handleClose();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [open]);
const reset = () => {
setLabel("");
setIconType("lucide");
setLucideName("Bot");
setColor("#0287FF");
setDataUrl("");
setServerId("");
setAssistantList([]);
setAssistantId("");
};
const handleClose = () => {
onOpenChange(false);
reset();
};
const handleAdd = () => {
const id = `custom-${nanoid(8)}`;
const icon: IconConfig =
iconType === "lucide"
? { type: "lucide", name: lucideName, color }
: { type: "custom", dataUrl, color };
const btn: any = {
id,
label: label || t("selection.custom.chat"),
icon,
action: {
type: "ask_ai",
assistantServerId: serverId || undefined,
assistantId: assistantId || undefined,
},
};
onAdd(btn);
handleClose();
};
const applyIconConfig = (cfg: IconConfig) => {
if (cfg.type === "lucide") {
setIconType("lucide");
setLucideName(String(cfg.name || lucideName || "Bot"));
} else {
setIconType("custom");
setDataUrl(String(cfg.dataUrl || dataUrl || ""));
}
if (cfg.color) {
setColor(String(cfg.color));
}
};
if (!open) return null;
const currentLanguage = language || i18n.language;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60"
onClick={handleClose}
>
<div
className="w-full max-w-lg rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-6 shadow-lg"
onClick={(e) => e.stopPropagation()}
>
<div className="mb-4">
<h2 className="text-lg font-semibold leading-none tracking-tight text-foreground">
{t("selection.custom.chat")}
</h2>
<p className="text-sm text-muted-foreground mt-1.5">
{t("selection.bind.assistant")}
</p>
</div>
<div className="space-y-4 py-2">
<div className="space-y-1.5">
<label className="text-sm font-medium text-muted-foreground">
{t("selection.custom.namePlaceholder")}
</label>
<input
className="rounded-md px-2 py-1 text-sm bg-white dark:bg-[#0B1220] w-full border border-[#E6E8EF] dark:border-[#2E3644]"
placeholder={t("selection.custom.namePlaceholder")}
value={label}
onChange={(e) => setLabel(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium text-muted-foreground">
{t("selection.icon.pick")}
</label>
<IconPicker
initial={
iconType === "lucide"
? { type: "lucide", name: lucideName, color }
: { type: "custom", dataUrl, color }
}
onChange={applyIconConfig}
theme={activeTheme}
locale={currentLanguage}
showLibraryLink={false}
controls={{
color: false,
size: false,
}}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<label className="text-sm font-medium text-muted-foreground">
{t("selection.bind.service")}
</label>
<select
className="h-8 rounded-md px-2 py-1 text-sm bg-white dark:bg-[#0B1220] w-full"
value={serverId}
onChange={(e) => setServerId(e.target.value)}
>
<option value="" disabled>
{t("selection.bind.defaultService")}
</option>
{serverList.map((s: any) => (
<option key={s.id} value={s.id}>
{s.name || s.endpoint || s.id}
</option>
))}
</select>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium text-muted-foreground">
{t("selection.bind.assistant")}
</label>
<select
className="h-8 rounded-md px-2 py-1 text-sm bg-white dark:bg-[#0B1220] w-full"
value={assistantId}
onChange={(e) => setAssistantId(e.target.value)}
disabled={loading || !serverId}
>
<option value="" disabled>
{loading
? t("common.loading")
: t("selection.bind.defaultAssistant")}
</option>
{!loading &&
assistantList.map((a: any) => (
<option key={a._id} value={a._id}>
{a._source?.name || a._id}
</option>
))}
</select>
</div>
</div>
</div>
<div className="flex justify-end gap-2 mt-6">
<Button variant="secondary" onClick={handleClose}>
{t("deleteDialog.button.cancel") ?? "Cancel"}
</Button>
<Button onClick={handleAdd}>
{t("settings.shortcut.save") ?? "Save"}
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,327 @@
import { useEffect, useRef, useState } from "react";
import { GripVertical, Trash2 } from "lucide-react";
import clsx from "clsx";
import { useTranslation } from "react-i18next";
import { AssistantFetcher } from "@/components/Assistant/AssistantFetcher";
import { setCurrentWindowService } from "@/commands/windowService";
import { AddChatButton } from "./AddChatButton";
import { ButtonConfig, resolveLucideIcon } from "./config";
const ASSISTANT_CACHE_KEY = "assistant_list_cache";
type AssistantCacheItem = {
list: any[];
updatedAt: number;
};
function loadAssistantCache(): Record<string, AssistantCacheItem> {
try {
const raw = localStorage.getItem(ASSISTANT_CACHE_KEY);
if (!raw) return {};
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === "object") return parsed;
return {};
} catch {
return {};
}
}
function saveAssistantCache(cache: Record<string, AssistantCacheItem>) {
try {
localStorage.setItem(ASSISTANT_CACHE_KEY, JSON.stringify(cache));
} catch (e) {
console.error("Persist assistant cache failed:", e);
}
}
type ButtonsListProps = {
buttons: ButtonConfig[];
setButtons: React.Dispatch<React.SetStateAction<ButtonConfig[]>>;
serverList: any[];
};
const ButtonsList = ({ buttons, setButtons, serverList }: ButtonsListProps) => {
const { t } = useTranslation();
const { fetchAssistant } = AssistantFetcher({});
const [assistantByServer, setAssistantByServer] = useState<
Record<string, any[]>
>({});
const [assistantLoadingByServer, setAssistantLoadingByServer] = useState<
Record<string, boolean>
>({});
const [assistantCache, setAssistantCacheState] = useState<
Record<string, AssistantCacheItem>
>(() => loadAssistantCache());
const BUILT_IN_IDS = new Set([
"search",
"ask_ai",
"translate",
"summary",
"copy",
"speak",
]);
const dragIndexRef = useRef<number | null>(null);
const initializedServiceRef = useRef<boolean>(false);
const onDragStart = (index: number) => {
dragIndexRef.current = index;
};
const onDrop = (index: number) => {
const from = dragIndexRef.current;
dragIndexRef.current = null;
if (from === null || from === index) return;
setButtons((prev) => {
const next = [...prev];
const [moved] = next.splice(from, 1);
next.splice(index, 0, moved);
return next;
});
};
const updateAction = (id: string, patch: Partial<ButtonConfig["action"]>) => {
setButtons((prev) =>
prev.map((b) =>
b.id === id ? { ...b, action: { ...b.action, ...patch } } : b
)
);
};
const handleAssistantSelect = (btn: ButtonConfig, value: string) => {
const id = value || undefined;
updateAction(btn.id, { assistantId: id });
};
const handleServerSelect = async (btn: ButtonConfig, serverId: string) => {
const sid = serverId || undefined;
try {
const target = serverList.find((s: any) => s.id === sid);
if (target) {
await setCurrentWindowService(target);
}
} catch (e) {
console.error("setCurrentWindowService failed:", e);
}
updateAction(btn.id, { assistantServerId: sid, assistantId: undefined });
if (!sid) return;
const cached = assistantCache[sid];
if (cached && Array.isArray(cached.list)) {
setAssistantByServer((prev) => ({ ...prev, [sid]: cached.list }));
}
setAssistantLoadingByServer((prev) => ({ ...prev, [sid]: true }));
try {
const data = await fetchAssistant({
current: 1,
pageSize: 1000,
serverId: sid,
});
const list = data.list || [];
setAssistantByServer((prev) => ({ ...prev, [sid]: list }));
const nextCache = {
...assistantCache,
[sid]: { list, updatedAt: Date.now() },
};
setAssistantCacheState(nextCache);
saveAssistantCache(nextCache);
} catch (err) {
console.error("Fetch assistants for server failed:", err);
setAssistantByServer((prev) => ({ ...prev, [sid]: [] }));
} finally {
setAssistantLoadingByServer((prev) => ({ ...prev, [sid]: false }));
}
};
useEffect(() => {
if (initializedServiceRef.current) return;
initializedServiceRef.current = true;
const preferredSid =
buttons.find((b) => b.action.assistantServerId)?.action
.assistantServerId || Object.keys(assistantCache)[0];
if (!preferredSid) return;
const target = serverList.find((s: any) => s.id === preferredSid);
if (!target) return;
setCurrentWindowService(target).catch((e) => {
console.error("init setCurrentWindowService failed:", e);
});
}, [serverList, buttons]);
useEffect(() => {
const uniqueServerIds = Array.from(
new Set(
buttons
.map((b) => b.action.assistantServerId)
.filter((sid): sid is string => Boolean(sid))
)
);
uniqueServerIds.forEach(async (sid) => {
if (!sid) return;
const cached = assistantCache[sid];
if (cached && Array.isArray(cached.list)) {
setAssistantByServer((prev) => ({ ...prev, [sid]: cached.list }));
}
setAssistantLoadingByServer((prev) => ({ ...prev, [sid]: true }));
try {
const data = await fetchAssistant({
current: 1,
pageSize: 1000,
serverId: sid,
});
const list = data.list || [];
setAssistantByServer((prev) => ({ ...prev, [sid]: list }));
const nextCache = {
...assistantCache,
[sid]: { list, updatedAt: Date.now() },
};
setAssistantCacheState(nextCache);
saveAssistantCache(nextCache);
} catch (err) {
console.error("Prefetch assistants for stored server failed:", err);
} finally {
setAssistantLoadingByServer((prev) => ({ ...prev, [sid]: false }));
}
});
}, [buttons]);
return (
<div className="space-y-3">
{buttons.map((btn, index) => {
const IconComp =
btn.icon.type === "lucide" ? resolveLucideIcon(btn.icon.name) : null;
const isChat = ["ask_ai", "translate", "summary"].includes(
btn.action.type
);
const isBuiltIn = BUILT_IN_IDS.has(btn.id);
const visualType: "Chat" | "Search" | "Tool" = isChat
? "Chat"
: btn.action.type === "search"
? "Search"
: "Tool";
return (
<div
key={btn.id}
className={clsx(
"rounded-lg border border-[#E5E7EB] dark:border-[#334155] bg-white dark:bg-[#0B1220] shadow-sm",
"p-3"
)}
draggable
onDragStart={() => onDragStart(index)}
onDragOver={(e) => e.preventDefault()}
onDrop={() => onDrop(index)}
>
<div className="flex items-center gap-3">
<GripVertical className="size-4 text-[#64748B] shrink-0" />
{IconComp ? (
<IconComp
className="size-4 shrink-0"
// style={{ color: btn.icon.color || "#6B7280" }}
/>
) : (
<img
src={(btn.icon as any).dataUrl}
alt="icon"
className="w-4 h-4 rounded shrink-0"
/>
)}
<span className="text-sm font-medium">
{btn.labelKey ? t(btn.labelKey) : btn.label}
</span>
<span
className={clsx(
"ml-2 inline-flex items-center rounded px-2 py-0.5 text-xs",
visualType === "Chat"
? "bg-[#0287FF]/10 text-[#0287FF]"
: visualType === "Search"
? "bg-[#6366F1]/10 text-[#6366F1]"
: "bg-[#64748B]/10 text-[#64748B]"
)}
>
{visualType}
</span>
<div className="ml-auto flex items-center gap-2">
{isChat && (
<>
<select
className="rounded-md px-2 py-1 text-sm bg-white dark:bg-[#0B1220] w-44"
value={btn.action.assistantServerId || ""}
onChange={(e) => handleServerSelect(btn, e.target.value)}
title={t("selection.bind.service")}
>
<option value="">
{t("selection.bind.defaultService")}
</option>
{serverList.map((s: any) => (
<option key={s.id} value={s.id}>
{s.name || s.endpoint || s.id}
</option>
))}
</select>
{(() => {
const sid = btn.action.assistantServerId;
const list = (sid && assistantByServer[sid]) || [];
const loading = !!(sid && assistantLoadingByServer[sid]);
return (
<select
className="rounded-md px-2 py-1 text-sm bg-white dark:bg-[#0B1220] w-44"
value={btn.action.assistantId || ""}
onChange={(e) =>
handleAssistantSelect(btn, e.target.value)
}
title={t("selection.bind.assistant")}
disabled={loading}
>
<option value="">
{t("selection.bind.defaultAssistant")}
</option>
{loading && (
<option value="" disabled>
{t("common.loading")}
</option>
)}
{list.map((a: any) => (
<option key={a._id} value={a._id}>
{a._source?.name || a._id}
</option>
))}
</select>
);
})()}
</>
)}
{!isBuiltIn && (
<button
type="button"
className="inline-flex items-center justify-center rounded-md border border-transparent bg-red-50 text-red-600 hover:bg-red-100 p-1"
title={t("selection.actions.delete")}
aria-label={t("selection.actions.delete")}
onClick={() =>
setButtons((prev) => prev.filter((b) => b.id !== btn.id))
}
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
</div>
</div>
);
})}
<AddChatButton
serverList={serverList}
onAdd={(newBtn) => setButtons((prev) => [...prev, newBtn])}
/>
</div>
);
};
export default ButtonsList;

View File

@@ -0,0 +1,50 @@
import {
Copy,
FileText,
Languages,
Search,
Volume2,
BotMessageSquare,
} from "lucide-react";
import * as LucideIcons from "lucide-react";
export type IconConfig =
| { type: "lucide"; name: string; color?: string }
| { type: "custom"; dataUrl: string; color?: string };
export type ActionConfig = {
type: string;
assistantId?: string;
assistantServerId?: string;
eventName?: string;
};
export type ButtonConfig = {
id: string;
label: string;
icon: IconConfig;
action: ActionConfig;
labelKey?: string;
};
export const LUCIDE_ICON_MAP: Record<string, any> = {
Search,
Languages,
FileText,
Copy,
Volume2,
BotMessageSquare,
};
export function resolveLucideIcon(name?: string): any {
if (!name) return (LucideIcons as any)["Search"] || Search;
const direct = (LucideIcons as any)[name];
if (direct) return direct;
const normalized = String(name)
.trim()
.replace(/[-_\s]+/g, " ")
.split(" ")
.map((s) => s.charAt(0).toUpperCase() + s.slice(1))
.join("");
return (LucideIcons as any)[normalized] || (LUCIDE_ICON_MAP as any)[normalized] || null;
}

View File

@@ -0,0 +1,177 @@
import { useEffect, useState } from "react";
import { Sparkles } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useSelectionStore } from "@/stores/selectionStore";
import SettingsToggle from "@/components/Settings/SettingsToggle";
import SettingsItem from "@/components/Settings/SettingsItem";
import { useEnabledServers } from "@/hooks/useEnabledServers";
import ButtonsList from "./ButtonsList";
import HeaderToolbar from "@/components/Selection/HeaderToolbar";
import { ButtonConfig } from "./config";
const DEFAULT_CONFIG: ButtonConfig[] = [
{
id: "search",
label: "搜索",
labelKey: "selection.actions.search",
icon: { type: "lucide", name: "Search", color: "#6366F1" },
action: { type: "search" },
},
{
id: "ask_ai",
label: "问答",
labelKey: "selection.actions.ask_ai",
icon: { type: "lucide", name: "BotMessageSquare", color: "#0287FF" },
action: { type: "ask_ai" },
},
{
id: "translate",
label: "翻译",
labelKey: "selection.actions.translate",
icon: { type: "lucide", name: "Languages", color: "#14B8A6" },
action: { type: "translate" },
},
{
id: "summary",
label: "总结",
labelKey: "selection.actions.summary",
icon: { type: "lucide", name: "FileText", color: "#0EA5E9" },
action: { type: "summary" },
},
{
id: "copy",
label: "复制",
labelKey: "selection.actions.copy",
icon: { type: "lucide", name: "Copy", color: "#64748B" },
action: { type: "copy" },
},
{
id: "speak",
label: "朗读",
labelKey: "selection.actions.speak",
icon: { type: "lucide", name: "Volume2", color: "#F59E0B" },
action: { type: "speak" },
},
];
const STORAGE_KEY = "selection_toolbar_config";
/**
* Utilities: load/save local toolbar config
*/
function loadToolbarConfig(): ButtonConfig[] {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return DEFAULT_CONFIG;
const parsed = JSON.parse(raw);
let cfg: ButtonConfig[] = Array.isArray(parsed) && parsed.length > 0 ? (parsed as ButtonConfig[]) : DEFAULT_CONFIG;
// Lightweight migration: ensure ask_ai icon follows the updated default
const defaultAsk = DEFAULT_CONFIG.find((b) => b.id === "ask_ai");
cfg = cfg.map((b) => (b.id === "ask_ai" && defaultAsk ? { ...b, icon: defaultAsk.icon } : b));
return cfg;
} catch {
return DEFAULT_CONFIG;
}
}
function saveToolbarConfig(cfg: ButtonConfig[]) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(cfg));
}
/**
* Selection settings panel: toolbar buttons with sorting and assistant mapping
*/
const SelectionSettings = () => {
const { t } = useTranslation();
// Reactive service and assistant list
const { enabledServers: serverList } = useEnabledServers();
const selectionEnabled = useSelectionStore((state) => state.selectionEnabled);
const iconsOnly = useSelectionStore((state) => state.iconsOnly);
const setIconsOnly = useSelectionStore((state) => state.setIconsOnly);
const setSelectionEnabled = useSelectionStore((state) => state.setSelectionEnabled);
// Initialize from global store; write back on change for multi-window sync
const toolbarConfig = useSelectionStore((s) => s.toolbarConfig);
const setToolbarConfig = useSelectionStore((s) => s.setToolbarConfig);
const [buttons, setButtons] = useState<ButtonConfig[]>(() =>
loadToolbarConfig()
);
useEffect(() => {
// prefer store config if present
if (Array.isArray(toolbarConfig) && toolbarConfig.length > 0) {
setButtons(toolbarConfig as ButtonConfig[]);
}
}, []);
useEffect(() => {
saveToolbarConfig(buttons);
setToolbarConfig(buttons);
}, [buttons]);
return (
<div className="space-y-6 py-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">{t("selection.title")}</h2>
</div>
<div className="relative rounded-xl p-4 bg-gradient-to-r from-[#E6F0FA] to-[#FFF1F1]">
<div className="flex items-center flex-col" aria-hidden="true">
<div className="rounded-xl border border-gray-200 bg-white/70 shadow-sm dark:border-gray-700 dark:bg-gray-900/40">
<HeaderToolbar
buttons={buttons as any}
iconsOnly={iconsOnly}
onAction={() => {}}
onLogoClick={() => {}}
/>
</div>
</div>
<div
className="absolute inset-0 bg-transparent cursor-not-allowed"
aria-label={t("selection.preview.readonly")}
tabIndex={-1}
/>
</div>
<SettingsItem
icon={Sparkles}
title={t("settings.ai.title")}
description={t("settings.ai.description")}
>
<SettingsToggle
checked={selectionEnabled}
onChange={setSelectionEnabled}
label={t("settings.ai.toggle")}
/>
</SettingsItem>
{selectionEnabled && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<SettingsItem
icon={Sparkles}
title={t("selection.display.title")}
description={t("selection.display.iconsOnlyDesc")}
>
<SettingsToggle
checked={iconsOnly}
onChange={async (value) => {
// Update local store
setIconsOnly(value);
}}
label={t("selection.display.iconsOnlyLabel")}
/>
</SettingsItem>
<ButtonsList
buttons={buttons}
setButtons={setButtons}
serverList={serverList}
/>
</div>
)}
</div>
);
};
export default SelectionSettings;

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