2 Commits

Author SHA1 Message Date
Steve Lau
9421180dba docs: release procedure 2025-09-28 11:18:43 +08:00
Steve Lau
db07dec505 docs: release procedure 2025-09-28 11:16:07 +08:00
231 changed files with 4264 additions and 11326 deletions

View File

@@ -5,8 +5,6 @@ on:
# Only run it when Frontend code changes # Only run it when Frontend code changes
paths: paths:
- 'src/**' - 'src/**'
- 'tsup.config.ts'
- 'package.json'
jobs: jobs:
check: check:
@@ -19,9 +17,6 @@ jobs:
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
ref: ${{ github.head_ref }}
fetch-depth: 0
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
@@ -35,36 +30,5 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
- name: Switch platformAdapter to Web adapter
shell: bash
run: >
node -e "const fs=require('fs');const f='src/utils/platformAdapter.ts';
let s=fs.readFileSync(f,'utf8');
s=s.replace(/import\\s*\\{\\s*createTauriAdapter\\s*\\}\\s*from\\s*\\\"\\.\\/tauriAdapter\\\";/,'import { createWebAdapter } from \\\"./webAdapter\\\";');
s=s.replace(/let\\s+platformAdapter\\s*=\\s*createTauriAdapter\\(\\);/,'let platformAdapter = createWebAdapter();');
fs.writeFileSync(f,s);"
- name: Build web (Tauri dependency check)
run: pnpm build:web
- name: Verify no Tauri refs in web output
shell: bash
run: |
if grep -R -n -E '@tauri-apps|tauri-plugin' out/search-chat; then
echo 'Tauri references found in web build output';
exit 1;
else
echo 'No Tauri references found';
fi
- name: Restore platformAdapter to Tauri adapter
shell: bash
run: >
node -e "const fs=require('fs');const f='src/utils/platformAdapter.ts';
let s=fs.readFileSync(f,'utf8');
s=s.replace(/import\\s*\\{\\s*createWebAdapter\\s*\\}\\s*from\\s*\\\"\\.\\/webAdapter\\\";/,'import { createTauriAdapter } from \\\"./tauriAdapter\\\";');
s=s.replace(/let\\s+platformAdapter\\s*=\\s*createWebAdapter\\(\\);/,'let platformAdapter = createTauriAdapter();');
fs.writeFileSync(f,s);"
- name: Build frontend - name: Build frontend
run: pnpm build run: pnpm build

View File

@@ -110,10 +110,10 @@ jobs:
# On Windows, we need to generate bindings for 'searchapi.h' using bindgen. # On Windows, we need to generate bindings for 'searchapi.h' using bindgen.
# And bindgen relies on 'libclang' # And bindgen relies on 'libclang'
# https://rust-lang.github.io/rust-bindgen/requirements.html#windows # https://rust-lang.github.io/rust-bindgen/requirements.html#windows
# - name: Install dependencies (Windows only)
# We don't need to install it because it is already included in GitHub if: startsWith(matrix.platform, 'windows-latest')
# Action runner image: shell: bash
# https://github.com/actions/runner-images/blob/main/images/windows/Windows2025-Readme.md#language-and-runtime run: winget install LLVM.LLVM --silent --accept-package-agreements --accept-source-agreements
- name: Add Rust build target - name: Add Rust build target

View File

@@ -35,10 +35,10 @@ jobs:
# On Windows, we need to generate bindings for 'searchapi.h' using bindgen. # On Windows, we need to generate bindings for 'searchapi.h' using bindgen.
# And bindgen relies on 'libclang' # And bindgen relies on 'libclang'
# https://rust-lang.github.io/rust-bindgen/requirements.html#windows # https://rust-lang.github.io/rust-bindgen/requirements.html#windows
# - name: Install dependencies (Windows only)
# We don't need to install it because it is already included in GitHub if: startsWith(matrix.platform, 'windows-latest')
# Action runner image: shell: bash
# https://github.com/actions/runner-images/blob/main/images/windows/Windows2025-Readme.md#language-and-runtime run: winget install LLVM.LLVM --silent --accept-package-agreements --accept-source-agreements
- name: Add pizza engine as a dependency - name: Add pizza engine as a dependency
working-directory: src-tauri working-directory: src-tauri

View File

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

56
RELEASE_PROCEDURE.md Normal file
View File

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

View File

@@ -1,22 +0,0 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/main.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}

View File

@@ -9,7 +9,7 @@ Coco AI is a fully open-source, cross-platform unified search and productivity t
{{% load-img "/img/coco-preview.gif" "" %}} {{% load-img "/img/coco-preview.gif" "" %}}
For more details on Coco Server, visit: [https://docs.infinilabs.com/coco-server/](https://docs.infinilabs.com/coco-server/). For more details on Coco Server, visit: [https://docs.infinilabs.com/coco-app/](https://docs.infinilabs.com/coco-app/).
## Community ## Community

View File

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

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

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

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

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

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

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

View File

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

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

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

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

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

@@ -1,33 +0,0 @@
---
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: 1 weight: 10
title: "Getting Started" title: "Getting Started"
bookCollapseSection: false bookCollapseSection: false
--- ---

View File

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

View File

@@ -13,77 +13,6 @@ Information about release notes of Coco App is provided here.
### 🐛 Bug fix ### 🐛 Bug fix
### ✈️ Improvements ### ✈️ 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
- fix: persist configuration settings properly #987
### ✈️ 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
### 🚀 Features
- feat: support switching groups via keyboard shortcuts #911
- feat: support opening logs from about page #915
- feat: support moving cursor with home and end keys #918
- feat: support pageup/pagedown to navigate search results #920
- feat: standardize multi-level menu label structure #925
- feat(View Extension): page field now accepts HTTP(s) links #925
- feat: return sub-exts when extension type exts themselves are matched #928
- feat: open quick ai with modifier key + enter #939
- feat: allow navigate back when cursor is at the beginning #940
- feat(extension compatibility): minimum_coco_version #946
- feat: add compact mode for window #947
- feat: advanced settings search debounce & local query source weight #950
- feat: add window opacity configuration option #963
- feat: add auto collapse delay for compact mode #981
### 🐛 Bug fix
- fix: automatic update of service list #913
- fix: duplicate chat content #916
- fix: resolve pinned window shortcut not working #917
- fix: WM ext does not work when operating focused win from another display #919
- fix(Window Management): Next/Previous Desktop do not work #926
- fix: fix page rapidly flickering issue #935
- fix(view extension): broken search bar UI when opening extensions via hotkey #938
- fix: allow deletion after selecting all text #943
- fix: prevent shaking when switching between chat and search pages #955
- fix: prevent duplicate login success messages #977
- fix: fix quick ai not continuing conversation #979
### ✈️ Improvements
- refactor: improve sorting logic of search results #910
- style: add dark drop shadow to images #912
- chore: add cross-domain configuration for web component #921
- refactor: retry if AXUIElementSetAttributeValue() does not work #924
- refactor(calculator): skip evaluation if expr is in form "num => num" #929
- chore: use a custom log directory #930
- chore: bump tauri_nspanel to v2.1 #933
- refactor: show_coco/hide_coco now use NSPanel's function on macOS #933
- refactor: procedure that convert_pages() into a func #934
- refactor(post-search): collect at least 2 documents from each query source #948
- refactor: custom_version_comparator() now compares semantic versions #941
- chore: center the main window vertically #959
- refactor(view extension): load HTML/resources via local HTTP server #973
## 0.8.0 (2025-09-28) ## 0.8.0 (2025-09-28)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 802 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 846 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 785 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 806 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 836 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 657 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 851 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 674 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 777 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 845 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 854 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 849 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 840 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 650 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 694 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 878 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 671 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 896 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 826 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 654 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 849 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 871 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 876 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 611 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 637 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 721 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 631 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 586 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 620 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 613 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 832 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 874 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 646 KiB

BIN
docs/static/img/release.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

View File

@@ -1,7 +1,7 @@
{ {
"name": "coco", "name": "coco",
"private": true, "private": true,
"version": "0.9.1", "version": "0.8.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -19,12 +19,8 @@
}, },
"dependencies": { "dependencies": {
"@headlessui/react": "^2.2.2", "@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/api": "^2.5.0",
"@tauri-apps/plugin-autostart": "~2.2.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-deep-link": "^2.2.1",
"@tauri-apps/plugin-dialog": "^2.2.1", "@tauri-apps/plugin-dialog": "^2.2.1",
"@tauri-apps/plugin-global-shortcut": "~2.0.0", "@tauri-apps/plugin-global-shortcut": "~2.0.0",
@@ -36,11 +32,9 @@
"@tauri-apps/plugin-shell": "^2.2.1", "@tauri-apps/plugin-shell": "^2.2.1",
"@tauri-apps/plugin-updater": "github:infinilabs/tauri-plugin-updater#v2", "@tauri-apps/plugin-updater": "github:infinilabs/tauri-plugin-updater#v2",
"@tauri-apps/plugin-window": "2.0.0-alpha.1", "@tauri-apps/plugin-window": "2.0.0-alpha.1",
"@tauri-store/zustand": "^1.1.0",
"@wavesurfer/react": "^1.0.11", "@wavesurfer/react": "^1.0.11",
"ahooks": "^3.8.4", "ahooks": "^3.8.4",
"axios": "^1.12.0", "axios": "^1.9.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"dotenv": "^16.5.0", "dotenv": "^16.5.0",
@@ -65,7 +59,6 @@
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"remark-math": "^6.0.0", "remark-math": "^6.0.0",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7",
"tauri-plugin-fs-pro-api": "^2.4.0", "tauri-plugin-fs-pro-api": "^2.4.0",
"tauri-plugin-macos-permissions-api": "^2.3.0", "tauri-plugin-macos-permissions-api": "^2.3.0",
"tauri-plugin-screenshots-api": "^2.2.0", "tauri-plugin-screenshots-api": "^2.2.0",

1198
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

2287
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "coco" name = "coco"
version = "0.9.1" version = "0.8.0"
description = "Search, connect, collaborate all in one place." description = "Search, connect, collaborate all in one place."
authors = ["INFINI Labs"] authors = ["INFINI Labs"]
edition = "2024" edition = "2024"
@@ -117,24 +117,17 @@ urlencoding = "2.1.3"
scraper = "0.17" scraper = "0.17"
toml = "0.8" toml = "0.8"
path-clean = "1.0.1" 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] [dev-dependencies]
tempfile = "3.23.0" tempfile = "3.23.0"
[target."cfg(target_os = \"macos\")".dependencies] [target."cfg(target_os = \"macos\")".dependencies]
tauri-nspanel = { git = "https://github.com/ahkohd/tauri-nspanel", branch = "v2.1" } tauri-nspanel = { git = "https://github.com/ahkohd/tauri-nspanel", branch = "v2" }
objc2-app-kit = { version = "0.3.1", features = ["NSWindow"] } objc2-app-kit = { version = "0.3.1", features = ["NSWindow"] }
objc2 = "0.6.2" objc2 = "0.6.2"
objc2-core-foundation = {version = "0.3.1", features = ["CFString", "CFCGTypes", "CFArray"] } objc2-core-foundation = {version = "0.3.1", features = ["CFString", "CFCGTypes", "CFArray"] }
objc2-application-services = { version = "0.3.1", features = ["HIServices"] } objc2-application-services = { version = "0.3.1", features = ["HIServices"] }
objc2-core-graphics = { version = "=0.3.1", features = ["CGEvent"] } 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] [target."cfg(target_os = \"linux\")".dependencies]
gio = "0.21.2" gio = "0.21.2"

View File

@@ -38,9 +38,5 @@
<string>Coco AI requires camera access for scanning documents and capturing images.</string> <string>Coco AI requires camera access for scanning documents and capturing images.</string>
<key>NSSpeechRecognitionUsageDescription</key> <key>NSSpeechRecognitionUsageDescription</key>
<string>Coco AI uses speech recognition to convert your voice into text for a hands-free experience.</string> <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> </dict>
</plist> </plist>

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,9 @@
// 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,10 +1,7 @@
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
use crate::extension::built_in::window_management::actions::Action; use crate::extension::built_in::window_management::actions::Action;
use crate::extension::view_extension::serve_files_in; use crate::extension::{ExtensionPermission, ExtensionSettings};
use crate::extension::{ExtensionPermission, ExtensionSettings, ViewExtensionUISettings};
use log::debug;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value as Json;
use std::collections::HashMap; use std::collections::HashMap;
use tauri::{AppHandle, Emitter}; use tauri::{AppHandle, Emitter};
@@ -88,15 +85,10 @@ pub(crate) enum ExtensionOnOpenedType {
open_with: Option<String>, open_with: Option<String>,
}, },
View { View {
/// Extension name
name: String,
// An absolute path to the extension icon or a font code.
icon: String,
/// Path to the HTML file that coco will load and render. /// Path to the HTML file that coco will load and render.
/// ///
/// It should be an absolute path or Tauri cannot open it. /// It should be an absolute path or Tauri cannot open it.
page: String, page: String,
ui: Option<ViewExtensionUISettings>,
}, },
} }
@@ -126,12 +118,7 @@ impl OnOpened {
// The URL of a quicklink is nearly useless without such dynamic user // The URL of a quicklink is nearly useless without such dynamic user
// inputs, so until we have dynamic URL support, we just use "N/A". // inputs, so until we have dynamic URL support, we just use "N/A".
ExtensionOnOpenedType::Quicklink { .. } => String::from("N/A"), ExtensionOnOpenedType::Quicklink { .. } => String::from("N/A"),
ExtensionOnOpenedType::View { ExtensionOnOpenedType::View { page: _ } => {
name: _,
icon: _,
page: _,
ui: _,
} => {
// We currently don't have URL for this kind of extension. // We currently don't have URL for this kind of extension.
String::from("N/A") String::from("N/A")
} }
@@ -145,7 +132,7 @@ impl OnOpened {
pub(crate) async fn open( pub(crate) async fn open(
tauri_app_handle: AppHandle, tauri_app_handle: AppHandle,
on_opened: OnOpened, on_opened: OnOpened,
extra_args: Option<HashMap<String, Json>>, extra_args: Option<HashMap<String, String>>,
) -> Result<(), String> { ) -> Result<(), String> {
use crate::util::open as homemade_tauri_shell_open; use crate::util::open as homemade_tauri_shell_open;
use std::process::Command; use std::process::Command;
@@ -244,49 +231,22 @@ pub(crate) async fn open(
} }
} }
} }
ExtensionOnOpenedType::View { ExtensionOnOpenedType::View { page } => {
name,
icon,
page,
ui,
} => {
let page_path = Utf8Path::new(&page);
let directory = page_path.parent().unwrap_or_else(|| {
panic!("View extension page path should have a parent, i.e., it should be under a directory, but [{}] does not", page);
});
let mut url = serve_files_in(directory.as_ref()).await;
/* /*
* Emit an event to let the frontend code open this extension. * Emit an event to let the frontend code open this extension.
* *
* Payload `view_extension_opened` contains the information needed * Payload `page_and_permission` contains the information needed
* to do that. * to do that.
* *
* See "src/pages/main/index.tsx" for more info. * See "src/pages/main/index.tsx" for more info.
*/ */
use camino::Utf8Path;
use serde_json::Value as Json; use serde_json::Value as Json;
use serde_json::to_value; use serde_json::to_value;
let html_filename = page_path let page_and_permission: [Json; 2] =
.file_name() [Json::String(page), to_value(permission).unwrap()];
.unwrap_or_else(|| {
panic!("View extension page path should have a file name, but [{}] does not have one", page);
}).to_string();
url.push('/');
url.push_str(&html_filename);
let html_file_url = url;
debug!("View extension listening on: {}", html_file_url);
let view_extension_opened: [Json; 5] = [
Json::String(name),
Json::String(icon),
Json::String(html_file_url),
to_value(permission).unwrap(),
to_value(ui).unwrap(),
];
tauri_app_handle tauri_app_handle
.emit("open_view_extension", view_extension_opened) .emit("open_view_extension", page_and_permission)
.unwrap(); .unwrap();
} }
} }

View File

@@ -1,162 +1,81 @@
use reqwest::StatusCode;
use serde::{Deserialize, Serialize, Serializer}; use serde::{Deserialize, Serialize, Serializer};
use snafu::prelude::*; use thiserror::Error;
use crate::server::http_client::HttpRequestError; fn serialize_optional_status_code<S>(
status_code: &Option<StatusCode>,
#[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, serializer: S,
) -> Result<S::Ok, S::Error> ) -> Result<S::Ok, S::Error>
where where
S: Serializer, S: Serializer,
{ {
serializer.serialize_str(&report_error(error, ReportErrorStyle::SingleLine)) match status_code {
} Some(code) => serializer.serialize_str(&format!("{:?}", code)),
None => serializer.serialize_none(),
/// `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,
}
/// 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;
match style {
ReportErrorStyle::SingleLine => {
let mut error_msg = format!("'{}'", e);
if let Some(cause) = e.source() {
error_msg.push_str(", caused by: [");
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)] #[allow(unused)]
mod tests { #[derive(Debug, Deserialize)]
use super::*; pub struct ErrorCause {
use std::io; #[serde(default)]
pub r#type: Option<String>,
#[derive(Debug, Snafu)] #[serde(default)]
enum Error { pub reason: Option<String>,
#[snafu(display("I/O Error"))]
Io { source: io::Error },
#[snafu(display("Foo"))]
Foo,
#[snafu(display("Nested"))]
Nested { source: ReadError },
} }
#[derive(Debug, Snafu)] #[derive(Debug, Deserialize)]
enum ReadError { #[allow(unused)]
#[snafu(display("failed to read config file"))] pub struct ErrorDetail {
ReadConfig { source: io::Error }, #[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>,
} }
#[test] #[derive(Debug, Deserialize)]
fn test_report_error_single_line_one_caused_by() { pub struct ErrorResponse {
let err = Error::Io { #[serde(default)]
source: io::Error::new(io::ErrorKind::NotFound, "file Cargo.toml not found"), pub error: Option<ErrorDetail>,
}; #[serde(default)]
#[allow(unused)]
let error_msg = report_error(&err, ReportErrorStyle::SingleLine); pub status: Option<u16>,
assert_eq!(
error_msg,
"'I/O Error', caused by: ['file Cargo.toml not found']"
);
} }
#[test] #[derive(Debug, Error, Serialize)]
fn test_report_error_single_line_multiple_caused_by() { pub enum SearchError {
let err = Error::Nested { #[error("HttpError: status code [{status_code:?}], msg [{msg}]")]
source: ReadError::ReadConfig { HttpError {
source: io::Error::new(io::ErrorKind::NotFound, "not found"), #[serde(serialize_with = "serialize_optional_status_code")]
status_code: Option<StatusCode>,
msg: String,
}, },
};
let error_msg = report_error(&err, ReportErrorStyle::SingleLine); #[error("ParseError: {0}")]
assert_eq!( ParseError(String),
error_msg,
"'Nested', caused by: ['failed to read config file' 'not found']" #[error("Timeout occurred")]
); Timeout,
#[error("InternalError: {0}")]
InternalError(String),
} }
#[test] impl From<reqwest::Error> for SearchError {
fn test_report_error_single_line_no_caused_by() { fn from(err: reqwest::Error) -> Self {
let err = Error::Foo; if err.is_timeout() {
SearchError::Timeout
let error_msg = report_error(&err, ReportErrorStyle::SingleLine); } else if err.is_decode() {
assert_eq!(error_msg, "'Foo'"); SearchError::ParseError(err.to_string())
} else {
SearchError::HttpError {
status_code: err.status(),
msg: err.to_string(),
}
}
} }
} }

View File

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

View File

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

View File

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

View File

@@ -100,7 +100,7 @@ impl SearchQuery {
} }
} }
#[derive(Debug, Clone, Serialize, Hash, PartialEq, Eq)] #[derive(Debug, Clone, Serialize)]
pub struct QuerySource { pub struct QuerySource {
pub r#type: String, //coco-server/local/ etc. pub r#type: String, //coco-server/local/ etc.
pub id: String, //coco server's id pub id: String, //coco server's id

View File

@@ -678,7 +678,7 @@ impl SearchSource for ApplicationSearchSource {
.expect("tx dropped, the runtime thread is possibly dead") .expect("tx dropped, the runtime thread is possibly dead")
.map_err(|pizza_engine_err| { .map_err(|pizza_engine_err| {
let err_str = pizza_engine_err.to_string(); let err_str = pizza_engine_err.to_string();
SearchError::InternalError { error: err_str } SearchError::InternalError(err_str)
})?; })?;
let total_hits = search_result.total_hits; let total_hits = search_result.total_hits;
@@ -1227,7 +1227,6 @@ pub async fn get_app_list(tauri_app_handle: AppHandle) -> Result<Vec<Extension>,
name, name,
platforms: None, platforms: None,
developer: None, developer: None,
minimum_coco_version: None,
// Leave it empty as it won't be used // Leave it empty as it won't be used
description: String::new(), description: String::new(),
icon: icon_path, icon: icon_path,
@@ -1243,7 +1242,6 @@ pub async fn get_app_list(tauri_app_handle: AppHandle) -> Result<Vec<Extension>,
enabled, enabled,
settings: None, settings: None,
page: None, page: None,
ui: None,
permission: None, permission: None,
screenshots: None, screenshots: None,
url: None, url: None,

View File

@@ -138,7 +138,7 @@ impl SearchSource for CalculatorSource {
// will only be evaluated against non-whitespace characters. // will only be evaluated against non-whitespace characters.
let query_string = query_string.trim(); let query_string = query_string.trim();
if query_string.is_empty() { if query_string.is_empty() || query_string.len() == 1 {
return Ok(QueryResponse { return Ok(QueryResponse {
source: self.get_type(), source: self.get_type(),
hits: Vec::new(), hits: Vec::new(),
@@ -150,26 +150,6 @@ impl SearchSource for CalculatorSource {
let query_source = self.get_type(); let query_source = self.get_type();
let base_score = self.base_score; let base_score = self.base_score;
let closure = move || -> QueryResponse { let closure = move || -> QueryResponse {
let Ok(tokens) = meval::tokenizer::tokenize(&query_string_clone) else {
// Invalid expression, return nothing.
return QueryResponse {
source: query_source,
hits: Vec::new(),
total_hits: 0,
};
};
// If it is only a number, no need to evaluate it as the result is
// this number.
// Actually, there is no need to return the result back to the users
// in such case because letting them know "x = x" is meaningless.
if tokens.len() == 1 && matches!(tokens[0], meval::tokenizer::Token::Number(_)) {
return QueryResponse {
source: query_source,
hits: Vec::new(),
total_hits: 0,
};
}
let res_num = meval::eval_str(&query_string_clone); let res_num = meval::eval_str(&query_string_clone);
match res_num { match res_num {

View File

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

View File

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

View File

@@ -8,7 +8,6 @@ use std::ffi::c_ushort;
use std::ffi::c_void; use std::ffi::c_void;
use std::ops::Deref; use std::ops::Deref;
use std::ptr::NonNull; use std::ptr::NonNull;
use std::time::Duration;
use objc2::MainThreadMarker; use objc2::MainThreadMarker;
use objc2_app_kit::NSEvent; use objc2_app_kit::NSEvent;
@@ -35,7 +34,6 @@ use objc2_core_graphics::CGEventType;
use objc2_core_graphics::CGMouseButton; use objc2_core_graphics::CGMouseButton;
use objc2_core_graphics::CGRectGetMidX; use objc2_core_graphics::CGRectGetMidX;
use objc2_core_graphics::CGRectGetMinY; use objc2_core_graphics::CGRectGetMinY;
use objc2_core_graphics::CGRectIntersectsRect;
use objc2_core_graphics::CGWindowID; use objc2_core_graphics::CGWindowID;
use super::error::Error; use super::error::Error;
@@ -48,7 +46,12 @@ use std::collections::HashMap;
use std::sync::{LazyLock, Mutex}; use std::sync::{LazyLock, Mutex};
fn intersects(r1: CGRect, r2: CGRect) -> bool { fn intersects(r1: CGRect, r2: CGRect) -> bool {
unsafe { CGRectIntersectsRect(r1, r2) } let overlapping = !(r1.origin.x + r1.size.width < r2.origin.x
|| r1.origin.y + r1.size.height < r2.origin.y
|| r1.origin.x > r2.origin.x + r2.size.width
|| r1.origin.y > r2.origin.y + r2.size.height);
overlapping
} }
/// Core graphics APIs use flipped coordinate system, while AppKit uses the /// Core graphics APIs use flipped coordinate system, while AppKit uses the
@@ -83,23 +86,6 @@ fn get_ui_element_origin(ui_element: &CFRetained<AXUIElement>) -> Result<CGPoint
Ok(position_cg_point) Ok(position_cg_point)
} }
/// Send a set origin request to the `ui_element`, return once request is sent.
fn set_ui_element_origin_oneshot(
ui_element: &CFRetained<AXUIElement>,
mut origin: CGPoint,
) -> Result<(), Error> {
let ptr_to_origin = NonNull::new((&mut origin as *mut CGPoint).cast::<c_void>()).unwrap();
let pos_value = unsafe { AXValue::new(AXValueType::CGPoint, ptr_to_origin) }.unwrap();
let pos_attr = CFString::from_static_str("AXPosition");
let error = unsafe { ui_element.set_attribute_value(&pos_attr, pos_value.deref()) };
if error != AXError::Success {
return Err(Error::AXError(error));
}
Ok(())
}
/// Helper function to extract an UI element's size. /// Helper function to extract an UI element's size.
fn get_ui_element_size(ui_element: &CFRetained<AXUIElement>) -> Result<CGSize, Error> { fn get_ui_element_size(ui_element: &CFRetained<AXUIElement>) -> Result<CGSize, Error> {
let mut size_value: *const CFType = std::ptr::null(); let mut size_value: *const CFType = std::ptr::null();
@@ -124,23 +110,6 @@ fn get_ui_element_size(ui_element: &CFRetained<AXUIElement>) -> Result<CGSize, E
Ok(size_cg_size) Ok(size_cg_size)
} }
/// Send a set size request to the `ui_element`, return once request is sent.
fn set_ui_element_size_oneshot(
ui_element: &CFRetained<AXUIElement>,
mut size: CGSize,
) -> Result<(), Error> {
let ptr_to_size = NonNull::new((&mut size as *mut CGSize).cast::<c_void>()).unwrap();
let size_value = unsafe { AXValue::new(AXValueType::CGSize, ptr_to_size) }.unwrap();
let size_attr = CFString::from_static_str("AXSize");
let error = unsafe { ui_element.set_attribute_value(&size_attr, size_value.deref()) };
if error != AXError::Success {
return Err(Error::AXError(error));
}
Ok(())
}
/// Get the frontmost/focused window (as an UI element). /// Get the frontmost/focused window (as an UI element).
fn get_frontmost_window() -> Result<CFRetained<AXUIElement>, Error> { fn get_frontmost_window() -> Result<CFRetained<AXUIElement>, Error> {
let workspace = unsafe { NSWorkspace::sharedWorkspace() }; let workspace = unsafe { NSWorkspace::sharedWorkspace() };
@@ -338,10 +307,6 @@ pub(crate) fn move_frontmost_window_to_workspace(space: usize) -> Result<(), Err
let window_frame = get_frontmost_window_frame()?; let window_frame = get_frontmost_window_frame()?;
let close_button_frame = get_frontmost_window_close_button_frame()?; let close_button_frame = get_frontmost_window_close_button_frame()?;
let prev_mouse_position = unsafe {
let event = CGEvent::new(None);
CGEvent::location(event.as_deref())
};
let mouse_cursor_point = CGPoint::new( let mouse_cursor_point = CGPoint::new(
unsafe { CGRectGetMidX(close_button_frame) }, unsafe { CGRectGetMidX(close_button_frame) },
@@ -395,9 +360,6 @@ pub(crate) fn move_frontmost_window_to_workspace(space: usize) -> Result<(), Err
CGEvent::post(CGEventTapLocation::HIDEventTap, mouse_drag_event.as_deref()); CGEvent::post(CGEventTapLocation::HIDEventTap, mouse_drag_event.as_deref());
} }
// Make a slight delay to make sure the window is grabbed
std::thread::sleep(Duration::from_millis(50));
// cast is safe as space is in range [1, 16] // cast is safe as space is in range [1, 16]
let hot_key: c_ushort = 118 + space as c_ushort - 1; let hot_key: c_ushort = 118 + space as c_ushort - 1;
@@ -440,30 +402,9 @@ pub(crate) fn move_frontmost_window_to_workspace(space: usize) -> Result<(), Err
); );
} }
// Make a slight delay to finish the space transition animation
std::thread::sleep(Duration::from_millis(50));
/*
* Cleanup
*/
unsafe { unsafe {
// Let go of the window. // Let go of the window.
CGEvent::post(CGEventTapLocation::HIDEventTap, mouse_up_event.as_deref()); CGEvent::post(CGEventTapLocation::HIDEventTap, mouse_up_event.as_deref());
// Reset mouse position
let mouse_reset_event = {
CGEvent::new_mouse_event(
None,
CGEventType::MouseMoved,
prev_mouse_position,
CGMouseButton::Left,
)
};
CGEvent::set_flags(mouse_reset_event.as_deref(), CGEventFlags(0));
CGEvent::post(
CGEventTapLocation::HIDEventTap,
mouse_reset_event.as_deref(),
);
} }
Ok(()) Ok(())
@@ -520,9 +461,6 @@ fn get_frontmost_window_close_button_frame() -> Result<CGRect, Error> {
/// 2. For non-main displays, it assumes that they don't have a menu bar, but macOS /// 2. For non-main displays, it assumes that they don't have a menu bar, but macOS
/// puts a menu bar on every display. /// puts a menu bar on every display.
/// ///
/// Update: This could be wrong, but looks like Apple fixed these 2 bugs in macOS
/// 26. At least the buggy behaviors disappear in my test.
///
/// ///
/// [^1]: Visible frame: a rectangle defines the portion of the screen in which it /// [^1]: Visible frame: a rectangle defines the portion of the screen in which it
/// is currently safe to draw your apps content. /// is currently safe to draw your apps content.
@@ -620,61 +558,27 @@ pub fn move_frontmost_window(x: f64, y: f64) -> Result<(), Error> {
/// Set the frontmost window's frame to the specified frame - adjust size and /// Set the frontmost window's frame to the specified frame - adjust size and
/// location at the same time. /// location at the same time.
///
/// This function **retries** up to `RETRY` times until the set operations
/// successfully get performed.
///
/// # Retry
///
/// Retry is added because I encountered a case where `AXUIElementSetAttributeValue()`
/// does not work in the expected way. When I execute the `NextDisplay` command
/// to move the focused window from a big display (2560x1440) to a small display
/// (1440*900), the window size could be set to 1460 sometimes. No idea if this
/// is a bug of the Accessibility APIs or due to the improper API uses. So we
/// retry for `RETRY` times at most to try our beest make it behave correctly.
pub fn set_frontmost_window_frame(frame: CGRect) -> Result<(), Error> { pub fn set_frontmost_window_frame(frame: CGRect) -> Result<(), Error> {
const RETRY: usize = 5;
/// Sleep for 50ms as I don't want to send too many requests to the focused
/// app and WindowServer because doing that could make them busy and then
/// they won't process my set requests.
///
/// The above is simply my observation, I don't know how the messaging really
/// works under the hood.
const SLEEP: Duration = Duration::from_millis(50);
let frontmost_window = get_frontmost_window()?; let frontmost_window = get_frontmost_window()?;
/* let mut point = frame.origin;
* Set window origin let ptr_to_point = NonNull::new((&mut point as *mut CGPoint).cast::<c_void>()).unwrap();
*/ let pos_value = unsafe { AXValue::new(AXValueType::CGPoint, ptr_to_point) }.unwrap();
set_ui_element_origin_oneshot(&frontmost_window, frame.origin)?; let pos_attr = CFString::from_static_str("AXPosition");
for _ in 0..RETRY {
std::thread::sleep(SLEEP);
let current = get_ui_element_origin(&frontmost_window)?; let error = unsafe { frontmost_window.set_attribute_value(&pos_attr, pos_value.deref()) };
if current == frame.origin { if error != AXError::Success {
break; return Err(Error::AXError(error));
} else {
set_ui_element_origin_oneshot(&frontmost_window, frame.origin)?;
}
} }
/* let mut size = frame.size;
* Set window size let ptr_to_size = NonNull::new((&mut size as *mut CGSize).cast::<c_void>()).unwrap();
*/ let size_value = unsafe { AXValue::new(AXValueType::CGSize, ptr_to_size) }.unwrap();
set_ui_element_size_oneshot(&frontmost_window, frame.size)?; let size_attr = CFString::from_static_str("AXSize");
for _ in 0..RETRY {
std::thread::sleep(SLEEP);
let current = get_ui_element_size(&frontmost_window)?; let error = unsafe { frontmost_window.set_attribute_value(&size_attr, size_value.deref()) };
// For size, we do not check if `current` has the exact same value as if error != AXError::Success {
// `frame.size` as I have encountered a case where I ask macOS to set return Err(Error::AXError(error));
// the height to 1550, but the height gets set to 1551.
if cgsize_roughly_equal(current, frame.size, 3.0) {
break;
} else {
set_ui_element_size_oneshot(&frontmost_window, frame.size)?;
}
} }
Ok(()) Ok(())
@@ -720,15 +624,6 @@ pub fn toggle_fullscreen() -> Result<(), Error> {
Ok(()) Ok(())
} }
/// Check if `lhs` roughly equals to `rhs`. The Roughness can be controlled by
/// argument `tolerance`.
fn cgsize_roughly_equal(lhs: CGSize, rhs: CGSize, tolerance: f64) -> bool {
let width_diff = (lhs.width - rhs.width).abs();
let height_diff = (lhs.height - rhs.height).abs();
width_diff <= tolerance && height_diff <= tolerance
}
static LAST_FRAME: LazyLock<Mutex<HashMap<CGWindowID, CGRect>>> = static LAST_FRAME: LazyLock<Mutex<HashMap<CGWindowID, CGRect>>> =
LazyLock::new(|| Mutex::new(HashMap::new())); LazyLock::new(|| Mutex::new(HashMap::new()));
@@ -741,56 +636,3 @@ pub(crate) fn get_frontmost_window_last_frame(window_id: CGWindowID) -> Option<C
let map = LAST_FRAME.lock().unwrap(); let map = LAST_FRAME.lock().unwrap();
map.get(&window_id).cloned() map.get(&window_id).cloned()
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_intersects_adjacent_rects_x() {
let r1 = CGRect::new(CGPoint::new(0.0, 0.0), CGSize::new(100.0, 100.0));
let r2 = CGRect::new(CGPoint::new(100.0, 0.0), CGSize::new(100.0, 100.0));
assert!(
!intersects(r1, r2),
"Adjacent rects on X should not intersect"
);
}
#[test]
fn test_intersects_adjacent_rects_y() {
let r1 = CGRect::new(CGPoint::new(0.0, 0.0), CGSize::new(100.0, 100.0));
let r2 = CGRect::new(CGPoint::new(0.0, 100.0), CGSize::new(100.0, 100.0));
assert!(
!intersects(r1, r2),
"Adjacent rects on Y should not intersect"
);
}
#[test]
fn test_intersects_overlapping_rects() {
let r1 = CGRect::new(CGPoint::new(0.0, 0.0), CGSize::new(100.0, 100.0));
let r2 = CGRect::new(CGPoint::new(50.0, 50.0), CGSize::new(100.0, 100.0));
assert!(intersects(r1, r2), "Overlapping rects should intersect");
}
#[test]
fn test_intersects_separate_rects() {
let r1 = CGRect::new(CGPoint::new(0.0, 0.0), CGSize::new(100.0, 100.0));
let r2 = CGRect::new(CGPoint::new(101.0, 101.0), CGSize::new(100.0, 100.0));
assert!(!intersects(r1, r2), "Separate rects should not intersect");
}
#[test]
fn test_intersects_contained_rect() {
let r1 = CGRect::new(CGPoint::new(0.0, 0.0), CGSize::new(100.0, 100.0));
let r2 = CGRect::new(CGPoint::new(10.0, 10.0), CGSize::new(50.0, 50.0));
assert!(intersects(r1, r2), "Contained rect should intersect");
}
#[test]
fn test_intersects_identical_rects() {
let r1 = CGRect::new(CGPoint::new(0.0, 0.0), CGSize::new(100.0, 100.0));
let r2 = CGRect::new(CGPoint::new(0.0, 0.0), CGSize::new(100.0, 100.0));
assert!(intersects(r1, r2), "Identical rects should intersect");
}
}

View File

@@ -28,7 +28,6 @@ use tauri_plugin_global_shortcut::GlobalShortcutExt;
use tauri_plugin_global_shortcut::ShortcutState; use tauri_plugin_global_shortcut::ShortcutState;
pub(crate) const EXTENSION_ID: &str = "Window Management"; pub(crate) const EXTENSION_ID: &str = "Window Management";
pub(crate) const EXTENSION_NAME_LOWERCASE: &str = "window management";
/// JSON file for this extension. /// JSON file for this extension.
pub(crate) const PLUGIN_JSON_FILE: &str = include_str!("./plugin.json"); pub(crate) const PLUGIN_JSON_FILE: &str = include_str!("./plugin.json");

View File

@@ -1,5 +1,4 @@
use super::EXTENSION_ID; use super::EXTENSION_ID;
use super::EXTENSION_NAME_LOWERCASE;
use crate::common::document::{DataSourceReference, Document}; use crate::common::document::{DataSourceReference, Document};
use crate::common::{ use crate::common::{
error::SearchError, error::SearchError,
@@ -57,7 +56,7 @@ impl SearchSource for WindowManagementSearchSource {
&get_built_in_extension_directory(&tauri_app_handle), &get_built_in_extension_directory(&tauri_app_handle),
super::EXTENSION_ID, super::EXTENSION_ID,
) )
.map_err(|e| SearchError::InternalError { error: e })?; .map_err(SearchError::InternalError)?;
let commands = extension.commands.expect("this extension has commands"); let commands = extension.commands.expect("this extension has commands");
let mut hits: Vec<(Document, f64)> = Vec::new(); let mut hits: Vec<(Document, f64)> = Vec::new();
@@ -82,16 +81,6 @@ impl SearchSource for WindowManagementSearchSource {
} }
} }
// An "extension" type extension should return all its
// sub-extensions when the query string matches its name.
// To do this, we score the extension name and take that
// into account.
if let Some(main_extension_score) =
calculate_text_similarity(&query_string_lowercase, &EXTENSION_NAME_LOWERCASE)
{
score += main_extension_score;
}
score score
}; };

View File

@@ -1,30 +1,22 @@
pub(crate) mod api; pub(crate) mod api;
pub(crate) mod built_in; pub(crate) mod built_in;
pub(crate) mod third_party; pub(crate) mod third_party;
pub(crate) mod view_extension;
use crate::common::document::ExtensionOnOpened; use crate::common::document::ExtensionOnOpened;
use crate::common::document::ExtensionOnOpenedType; use crate::common::document::ExtensionOnOpenedType;
use crate::common::document::OnOpened; use crate::common::document::OnOpened;
use crate::common::error::ReportErrorStyle;
use crate::common::error::report_error;
use crate::common::register::SearchSourceRegistry; use crate::common::register::SearchSourceRegistry;
use crate::util::platform::Platform; use crate::util::platform::Platform;
use crate::util::version::COCO_VERSION;
use crate::util::version::parse_coco_semver;
use anyhow::Context; use anyhow::Context;
use bitflags::bitflags; use bitflags::bitflags;
use borrowme::{Borrow, ToOwned}; use borrowme::{Borrow, ToOwned};
use derive_more::Display; use derive_more::Display;
use indexmap::IndexMap; use indexmap::IndexMap;
use semver::Version as SemVer;
use serde::Deserialize; use serde::Deserialize;
use serde::Serialize; use serde::Serialize;
use serde_json::Value as Json; use serde_json::Value as Json;
use std::collections::HashMap; use std::collections::HashMap;
use std::collections::HashSet; use std::collections::HashSet;
use std::io;
use std::ops::Deref;
use std::path::Path; use std::path::Path;
use tauri::{AppHandle, Manager}; use tauri::{AppHandle, Manager};
use third_party::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE; use third_party::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE;
@@ -32,7 +24,6 @@ use third_party::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE;
pub const LOCAL_QUERY_SOURCE_TYPE: &str = "local"; pub const LOCAL_QUERY_SOURCE_TYPE: &str = "local";
const PLUGIN_JSON_FILE_NAME: &str = "plugin.json"; const PLUGIN_JSON_FILE_NAME: &str = "plugin.json";
const ASSETS_DIRECTORY_FILE_NAME: &str = "assets"; const ASSETS_DIRECTORY_FILE_NAME: &str = "assets";
const PLUGIN_JSON_FIELD_MINIMUM_COCO_VERSION: &str = "minimum_coco_version";
fn default_true() -> bool { fn default_true() -> bool {
true true
@@ -48,9 +39,10 @@ pub struct Extension {
name: String, name: String,
/// ID of the developer. /// ID of the developer.
/// ///
/// * For built-in extensions, this is None. /// * For built-in extensions, this will always be None.
/// * For third-party main extensions, this field contains the extension developer ID. /// * For third-party first-layer extensions, the on-disk plugin.json file
/// * For third-party sub extensions, this field is be None. /// won't contain this field, but we will set this field for them after reading them into the memory.
/// * For third-party sub extensions, this field will be None.
developer: Option<String>, developer: Option<String>,
/// Platforms supported by this extension. /// Platforms supported by this extension.
/// ///
@@ -118,26 +110,11 @@ pub struct Extension {
/// For View extensions, path to the HTML file/page that coco will load /// For View extensions, path to the HTML file/page that coco will load
/// and render. Otherwise, `None`. /// and render. Otherwise, `None`.
///
/// It could be a path relative to the extension root directory, Coco will
/// canonicalize it in that case.
page: Option<String>, page: Option<String>,
ui: Option<ViewExtensionUISettings>,
/// Permission that this extension requires. /// Permission that this extension requires.
permission: Option<ExtensionPermission>, permission: Option<ExtensionPermission>,
/// The version of Coco app that this extension requires.
///
/// If not set, then this extension is compatible with all versions of Coco app.
///
/// It is only for third-party extensions. Built-in extensions should always
/// set this field to `None`.
#[serde(deserialize_with = "deserialize_coco_semver")]
#[serde(default)] // None if this field is missing
minimum_coco_version: Option<SemVer>,
/* /*
* The following fields are currently useless to us but are needed by our * The following fields are currently useless to us but are needed by our
* extension store. * extension store.
@@ -149,17 +126,6 @@ pub struct Extension {
version: Option<Json>, version: Option<Json>,
} }
/// Settings that control the built-in UI Components
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
pub(crate) struct ViewExtensionUISettings {
/// Show the search bar
search_bar: bool,
/// Show the filter bar
filter_bar: bool,
/// Show the footer
footer: bool,
}
/// Bundle ID uniquely identifies an extension. /// Bundle ID uniquely identifies an extension.
#[derive(Debug, Deserialize, Serialize, PartialEq, Clone)] #[derive(Debug, Deserialize, Serialize, PartialEq, Clone)]
pub(crate) struct ExtensionBundleId { pub(crate) struct ExtensionBundleId {
@@ -296,19 +262,11 @@ impl Extension {
ExtensionType::Script => todo!("not supported yet"), ExtensionType::Script => todo!("not supported yet"),
ExtensionType::Setting => todo!("not supported yet"), ExtensionType::Setting => todo!("not supported yet"),
ExtensionType::View => { ExtensionType::View => {
let name = self.name.clone();
let icon = self.icon.clone();
let page = self.page.as_ref().unwrap_or_else(|| { let page = self.page.as_ref().unwrap_or_else(|| {
panic!("View extension [{}]'s [page] field is not set, something wrong with your extension validity check", self.id); panic!("View extension [{}]'s [page] field is not set, something wrong with your extension validity check", self.id);
}).clone(); }).clone();
let ui = self.ui.clone();
let extension_on_opened_type = ExtensionOnOpenedType::View { let extension_on_opened_type = ExtensionOnOpenedType::View { page };
name,
icon,
page,
ui,
};
let extension_on_opened = ExtensionOnOpened { let extension_on_opened = ExtensionOnOpened {
ty: extension_on_opened_type, ty: extension_on_opened_type,
settings, settings,
@@ -318,9 +276,6 @@ impl Extension {
Some(on_opened) Some(on_opened)
} }
ExtensionType::Unknown => {
unreachable!("Extensions of type [Unknown] should never be opened")
}
} }
} }
@@ -395,30 +350,6 @@ impl Extension {
} }
} }
/// Deserialize Coco SemVer from a string.
///
/// This function adapts `parse_coco_semver` to work with serde's `deserialize_with`
/// attribute.
fn deserialize_coco_semver<'de, D>(deserializer: D) -> Result<Option<SemVer>, D::Error>
where
D: serde::Deserializer<'de>,
{
let version_str: Option<String> = Option::deserialize(deserializer)?;
let Some(version_str) = version_str else {
return Ok(None);
};
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))
}
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] #[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
pub(crate) struct CommandAction { pub(crate) struct CommandAction {
pub(crate) exec: String, pub(crate) exec: String,
@@ -485,7 +416,7 @@ impl QuicklinkLink {
/// if any. /// if any.
pub(crate) fn concatenate_url( pub(crate) fn concatenate_url(
&self, &self,
user_supplied_args: &Option<HashMap<String, Json>>, user_supplied_args: &Option<HashMap<String, String>>,
) -> String { ) -> String {
let mut out = String::new(); let mut out = String::new();
for component in self.components.iter() { for component in self.components.iter() {
@@ -497,23 +428,20 @@ impl QuicklinkLink {
argument_name, argument_name,
default, default,
} => { } => {
let opt_argument_value: Option<&str> = { let opt_argument_value = {
let user_supplied_arg = user_supplied_args let user_supplied_arg = user_supplied_args
.as_ref() .as_ref()
.and_then(|map| map.get(argument_name.as_str())); .and_then(|map| map.get(argument_name.as_str()));
if user_supplied_arg.is_some() { if user_supplied_arg.is_some() {
user_supplied_arg.map(|json| { user_supplied_arg
json.as_str()
.expect("quicklink should provide string arguments")
})
} else { } else {
default.as_deref() default.as_ref()
} }
}; };
let argument_value_str = match opt_argument_value { let argument_value_str = match opt_argument_value {
Some(str) => str, Some(str) => str.as_str(),
// None => an empty string // None => an empty string
None => "", None => "",
}; };
@@ -599,7 +527,7 @@ pub(crate) enum QuicklinkLinkComponent {
}, },
} }
#[derive(Debug, PartialEq, Deserialize, Serialize, Clone, Display, Copy, Eq)] #[derive(Debug, PartialEq, Deserialize, Serialize, Clone, Display, Copy)]
#[serde(rename_all(serialize = "snake_case", deserialize = "snake_case"))] #[serde(rename_all(serialize = "snake_case", deserialize = "snake_case"))]
pub enum ExtensionType { pub enum ExtensionType {
#[display("Group")] #[display("Group")]
@@ -622,10 +550,6 @@ pub enum ExtensionType {
AiExtension, AiExtension,
#[display("View")] #[display("View")]
View, View,
/// Add this variant for better compatibility: Future versions of Coco may
/// add new extension types that older versions of Coco are not aware of.
#[display("Unknown")]
Unknown,
} }
impl ExtensionType { impl ExtensionType {
@@ -873,22 +797,6 @@ pub(crate) async fn init_extensions(tauri_app_handle: &AppHandle) -> Result<(),
Ok(()) Ok(())
} }
/// Is `extension` compatible with the current running Coco app?
///
/// It is defined as a tauri command rather than an associated function because
/// it will be used in frontend code as well.
///
/// Async tauri commands are required to return `Result<T, E>`, this function
/// only needs to return a boolean, so it is not marked async.
#[tauri::command]
pub(crate) fn is_extension_compatible(extension: Extension) -> bool {
let Some(ref minimum_coco_version) = extension.minimum_coco_version else {
return true;
};
COCO_VERSION.deref() >= minimum_coco_version
}
#[tauri::command] #[tauri::command]
pub(crate) async fn enable_extension( pub(crate) async fn enable_extension(
tauri_app_handle: AppHandle, tauri_app_handle: AppHandle,
@@ -987,22 +895,15 @@ pub(crate) async fn is_extension_enabled(
pub(crate) fn canonicalize_relative_icon_path( pub(crate) fn canonicalize_relative_icon_path(
extension_dir: &Path, extension_dir: &Path,
extension: &mut Extension, extension: &mut Extension,
) -> Result<(), io::Error> { ) -> Result<(), String> {
fn _canonicalize_relative_icon_path( fn _canonicalize_relative_icon_path(
extension_dir: &Path, extension_dir: &Path,
extension: &mut Extension, extension: &mut Extension,
) -> Result<(), io::Error> { ) -> Result<(), String> {
let icon_str = &extension.icon; let icon_str = &extension.icon;
let icon_path = Path::new(icon_str); let icon_path = Path::new(icon_str);
if icon_path.is_relative() { if icon_path.is_relative() {
// If we enter this if statement, then there are 2 possible cases:
//
// 1. icon_path is a font class code, e.g., "font_coco"
// 2. icon_path is a indeed a relative path
//
// We distinguish between these 2 cases by checking if `absolute_icon_path` exists
let absolute_icon_path = { let absolute_icon_path = {
let mut assets_directory = extension_dir.join(ASSETS_DIRECTORY_FILE_NAME); let mut assets_directory = extension_dir.join(ASSETS_DIRECTORY_FILE_NAME);
assets_directory.push(icon_path); assets_directory.push(icon_path);
@@ -1010,7 +911,7 @@ pub(crate) fn canonicalize_relative_icon_path(
assets_directory assets_directory
}; };
if absolute_icon_path.try_exists()? { if absolute_icon_path.try_exists().map_err(|e| e.to_string())? {
extension.icon = absolute_icon_path extension.icon = absolute_icon_path
.into_os_string() .into_os_string()
.into_string() .into_string()
@@ -1053,29 +954,21 @@ pub(crate) fn canonicalize_relative_icon_path(
pub(crate) fn canonicalize_relative_page_path( pub(crate) fn canonicalize_relative_page_path(
extension_dir: &Path, extension_dir: &Path,
extension: &mut Extension, extension: &mut Extension,
) -> Result<(), io::Error> { ) -> Result<(), String> {
fn _canonicalize_view_extension_page_path( fn _canonicalize_view_extension_page_path(
extension_dir: &Path, extension_dir: &Path,
extension: &mut Extension, extension: &mut Extension,
) -> Result<(), io::Error> { ) -> Result<(), String> {
let page = extension let page = extension
.page .page
.as_ref() .as_ref()
.expect("this should be invoked on a View extension"); .expect("this should be invoked on a View extension");
// Skip HTTP links
if let Ok(url) = url::Url::parse(page)
&& ["http", "https"].contains(&url.scheme())
{
return Ok(());
}
let page_path = Path::new(page); let page_path = Path::new(page);
if page_path.is_relative() { if page_path.is_relative() {
let absolute_page_path = extension_dir.join(page_path); let absolute_page_path = extension_dir.join(page_path);
if absolute_page_path.try_exists()? { if absolute_page_path.try_exists().map_err(|e| e.to_string())? {
extension.page = Some( extension.page = Some(
absolute_page_path absolute_page_path
.into_os_string() .into_os_string()
@@ -1848,7 +1741,7 @@ mod tests {
], ],
}; };
let mut user_args = HashMap::new(); let mut user_args = HashMap::new();
user_args.insert("other_param".to_string(), Json::String("value".to_string())); user_args.insert("other_param".to_string(), "value".to_string());
let result = link.concatenate_url(&Some(user_args)); let result = link.concatenate_url(&Some(user_args));
assert_eq!(result, "https://www.google.com/search?q="); assert_eq!(result, "https://www.google.com/search?q=");
} }
@@ -1885,7 +1778,7 @@ mod tests {
], ],
}; };
let mut user_args = HashMap::new(); let mut user_args = HashMap::new();
user_args.insert("other_param".to_string(), Json::String("value".to_string())); user_args.insert("other_param".to_string(), "value".to_string());
let result = link.concatenate_url(&Some(user_args)); let result = link.concatenate_url(&Some(user_args));
assert_eq!(result, "https://www.google.com/search?q=rust"); assert_eq!(result, "https://www.google.com/search?q=rust");
} }
@@ -1908,7 +1801,7 @@ mod tests {
], ],
}; };
let mut user_args = HashMap::new(); let mut user_args = HashMap::new();
user_args.insert("query".to_string(), Json::String("python".to_string())); user_args.insert("query".to_string(), "python".to_string());
let result = link.concatenate_url(&Some(user_args)); let result = link.concatenate_url(&Some(user_args));
assert_eq!(result, "https://www.google.com/search?q=python"); assert_eq!(result, "https://www.google.com/search?q=python");
} }

View File

@@ -15,66 +15,12 @@
use crate::extension::Extension; use crate::extension::Extension;
use crate::extension::ExtensionType; use crate::extension::ExtensionType;
use crate::util::platform::Platform; use crate::util::platform::Platform;
use derive_more::Display;
use serde::Serialize;
use std::collections::HashSet; use std::collections::HashSet;
use std::error::Error;
use std::fmt::Display;
/// Errors that may be found when we check() `plugin.json`, i.e., `struct Extension` pub(crate) fn general_check(extension: &Extension) -> Result<(), String> {
#[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
check_main_extension_only(extension)?; check_main_extension_only(extension)?;
check_main_extension_or_sub_extension(extension, false)?; check_main_extension_or_sub_extension(extension, &format!("extension [{}]", extension.id))?;
// `None` if `extension` is compatible with all the platforms. Otherwise `Some(limited_platforms)` // `None` if `extension` is compatible with all the platforms. Otherwise `Some(limited_platforms)`
let limited_supported_platforms = match extension.platforms.as_ref() { let limited_supported_platforms = match extension.platforms.as_ref() {
@@ -109,17 +55,18 @@ pub(crate) fn general_check(extension: &Extension) -> Result<(), InvalidPluginJs
let mut sub_extension_ids = HashSet::new(); let mut sub_extension_ids = HashSet::new();
for sub_extension in sub_extensions.iter() { for sub_extension in sub_extensions.iter() {
check_sub_extension_only(sub_extension, limited_supported_platforms)?; check_sub_extension_only(&extension.id, sub_extension, limited_supported_platforms)?;
check_main_extension_or_sub_extension(extension, true)?; check_main_extension_or_sub_extension(
extension,
&format!("sub-extension [{}-{}]", extension.id, sub_extension.id),
)?;
if !sub_extension_ids.insert(sub_extension.id.as_str()) { if !sub_extension_ids.insert(sub_extension.id.as_str()) {
// extension ID already exists // extension ID already exists
return Err(InvalidPluginJsonError { return Err(format!(
sub_extension_id: Some(sub_extension.id.clone()), "sub-extension with ID [{}] already exists",
kind: InvalidPluginJsonErrorKind::DuplicateSubExtensionId { sub_extension.id
id: sub_extension.id.clone(), ));
},
});
} }
} }
@@ -127,33 +74,27 @@ pub(crate) fn general_check(extension: &Extension) -> Result<(), InvalidPluginJs
} }
/// This checks the main extension only, it won't check sub-extensions. /// This checks the main extension only, it won't check sub-extensions.
fn check_main_extension_only(extension: &Extension) -> Result<(), InvalidPluginJsonError> { fn check_main_extension_only(extension: &Extension) -> Result<(), String> {
// Helper closure to construct `InvalidPluginJsonError` easily.
let err = |kind| InvalidPluginJsonError {
sub_extension_id: None,
kind,
};
// Group and Extension cannot have alias // Group and Extension cannot have alias
if extension.alias.is_some() if extension.alias.is_some() {
&& (extension.r#type == ExtensionType::Group if extension.r#type == ExtensionType::Group || extension.r#type == ExtensionType::Extension
|| extension.r#type == ExtensionType::Extension)
{ {
return Err(err(InvalidPluginJsonErrorKind::FieldsNotAllowed { return Err(format!(
fields: &["alias"], "invalid extension [{}], extension of type [{:?}] cannot have alias",
ty: extension.r#type, extension.id, extension.r#type
})); ));
}
} }
// Group and Extension cannot have hotkey // Group and Extension cannot have hotkey
if extension.hotkey.is_some() if extension.hotkey.is_some() {
&& (extension.r#type == ExtensionType::Group if extension.r#type == ExtensionType::Group || extension.r#type == ExtensionType::Extension
|| extension.r#type == ExtensionType::Extension)
{ {
return Err(err(InvalidPluginJsonErrorKind::FieldsNotAllowed { return Err(format!(
fields: &["hotkey"], "invalid extension [{}], extension of type [{:?}] cannot have hotkey",
ty: extension.r#type, extension.id, extension.r#type
})); ));
}
} }
if extension.commands.is_some() if extension.commands.is_some()
@@ -163,20 +104,20 @@ fn check_main_extension_only(extension: &Extension) -> Result<(), InvalidPluginJ
{ {
if extension.r#type != ExtensionType::Group && extension.r#type != ExtensionType::Extension if extension.r#type != ExtensionType::Group && extension.r#type != ExtensionType::Extension
{ {
return Err(err(InvalidPluginJsonErrorKind::FieldsNotAllowed { return Err(format!(
fields: &["commands", "scripts", "quicklinks", "views"], "invalid extension [{}], only extension of type [Group] and [Extension] can have sub-extensions",
ty: extension.r#type, extension.id,
})); ));
} }
} }
if extension.settings.is_some() { if extension.settings.is_some() {
// Sub-extensions are all searchable, so this check is only for main extensions. // Sub-extensions are all searchable, so this check is only for main extensions.
if !extension.searchable() { if !extension.searchable() {
return Err(err(InvalidPluginJsonErrorKind::FieldsNotAllowed { return Err(format!(
fields: &["settings"], "invalid extension {}, field [settings] is currently only allowed in searchable extension, this type of extension is not searchable [{}]",
ty: extension.r#type, extension.id, extension.r#type
})); ));
} }
} }
@@ -184,21 +125,16 @@ fn check_main_extension_only(extension: &Extension) -> Result<(), InvalidPluginJ
} }
fn check_sub_extension_only( fn check_sub_extension_only(
extension_id: &str,
sub_extension: &Extension, sub_extension: &Extension,
limited_platforms: Option<&HashSet<Platform>>, limited_platforms: Option<&HashSet<Platform>>,
) -> Result<(), InvalidPluginJsonError> { ) -> Result<(), String> {
let err = |kind| InvalidPluginJsonError {
sub_extension_id: Some(sub_extension.id.clone()),
kind,
};
if sub_extension.r#type == ExtensionType::Group if sub_extension.r#type == ExtensionType::Group
|| sub_extension.r#type == ExtensionType::Extension || sub_extension.r#type == ExtensionType::Extension
{ {
return Err(err( return Err(format!(
InvalidPluginJsonErrorKind::TypesNotAllowedForSubExtension { "invalid sub-extension [{}-{}]: sub-extensions should not be of type [Group] or [Extension]",
types: &[ExtensionType::Group, ExtensionType::Extension], extension_id, sub_extension.id
},
)); ));
} }
@@ -207,18 +143,16 @@ fn check_sub_extension_only(
|| sub_extension.quicklinks.is_some() || sub_extension.quicklinks.is_some()
|| sub_extension.views.is_some() || sub_extension.views.is_some()
{ {
return Err(err( return Err(format!(
InvalidPluginJsonErrorKind::FieldsNotAllowedForSubExtension { "invalid sub-extension [{}-{}]: fields [commands/scripts/quicklinks/views] should not be set in sub-extensions",
fields: &["commands", "scripts", "quicklinks", "views"], extension_id, sub_extension.id
},
)); ));
} }
if sub_extension.developer.is_some() { if sub_extension.developer.is_some() {
return Err(err( return Err(format!(
InvalidPluginJsonErrorKind::FieldsNotAllowedForSubExtension { "invalid sub-extension [{}-{}]: field [developer] should not be set in sub-extensions",
fields: &["developer"], extension_id, sub_extension.id
},
)); ));
} }
@@ -232,10 +166,9 @@ fn check_sub_extension_only(
.collect::<Vec<String>>(); .collect::<Vec<String>>();
if !diff.is_empty() { if !diff.is_empty() {
return Err(err( return Err(format!(
InvalidPluginJsonErrorKind::SubExtensionHasMoreSupportedPlatforms { "invalid sub-extension [{}-{}]: it supports platforms {:?} that are not supported by the main extension",
extra_platforms: diff, extension_id, sub_extension.id, diff
},
)); ));
} }
} }
@@ -246,77 +179,56 @@ fn check_sub_extension_only(
} }
} }
if sub_extension.minimum_coco_version.is_some() {
return Err(err(
InvalidPluginJsonErrorKind::FieldsNotAllowedForSubExtension {
fields: &["minimum_coco_version"],
},
));
}
Ok(()) Ok(())
} }
fn check_main_extension_or_sub_extension( fn check_main_extension_or_sub_extension(
extension: &Extension, extension: &Extension,
is_sub_extension: bool, identifier: &str,
) -> Result<(), InvalidPluginJsonError> { ) -> Result<(), String> {
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 field `action` is Some, then it should be a Command
if extension.action.is_some() && extension.r#type != ExtensionType::Command { if extension.action.is_some() && extension.r#type != ExtensionType::Command {
return Err(err(InvalidPluginJsonErrorKind::FieldsNotAllowed { return Err(format!(
fields: &["action"], "invalid {}, field [action] is set for a non-Command extension",
ty: extension.r#type, identifier
})); ));
} }
if extension.r#type == ExtensionType::Command && extension.action.is_none() { if extension.r#type == ExtensionType::Command && extension.action.is_none() {
return Err(err(InvalidPluginJsonErrorKind::FieldRequired { return Err(format!(
field: "action", "invalid {}, field [action] should be set for a Command extension",
ty: extension.r#type, identifier
})); ));
} }
// If field `quicklink` is Some, then it should be a Quicklink // If field `quicklink` is Some, then it should be a Quicklink
if extension.quicklink.is_some() && extension.r#type != ExtensionType::Quicklink { if extension.quicklink.is_some() && extension.r#type != ExtensionType::Quicklink {
return Err(err(InvalidPluginJsonErrorKind::FieldsNotAllowed { return Err(format!(
fields: &["quicklink"], "invalid {}, field [quicklink] is set for a non-Quicklink extension",
ty: extension.r#type, identifier
})); ));
} }
if extension.r#type == ExtensionType::Quicklink && extension.quicklink.is_none() { if extension.r#type == ExtensionType::Quicklink && extension.quicklink.is_none() {
return Err(err(InvalidPluginJsonErrorKind::FieldRequired { return Err(format!(
field: "quicklink", "invalid {}, field [quicklink] should be set for a Quicklink extension",
ty: extension.r#type, identifier
})); ));
} }
// If field `page` is Some, then it should be a View // If field `page` is Some, then it should be a View
if extension.page.is_some() && extension.r#type != ExtensionType::View { if extension.page.is_some() && extension.r#type != ExtensionType::View {
return Err(err(InvalidPluginJsonErrorKind::FieldsNotAllowed { return Err(format!(
fields: &["page"], "invalid {}, field [page] is set for a non-View extension",
ty: extension.r#type, identifier
})); ));
} }
if extension.r#type == ExtensionType::View && extension.page.is_none() { if extension.r#type == ExtensionType::View && extension.page.is_none() {
return Err(err(InvalidPluginJsonErrorKind::FieldRequired { return Err(format!(
field: "page", "invalid {}, field [page] should be set for a View extension",
ty: extension.r#type, identifier
})); ));
}
// If field `ui` is Some, then it should be a View
if extension.ui.is_some() && extension.r#type != ExtensionType::View {
return Err(err(InvalidPluginJsonErrorKind::FieldsNotAllowed {
fields: &["ui"],
ty: extension.r#type,
}));
} }
Ok(()) Ok(())
@@ -355,10 +267,8 @@ mod tests {
hotkey: None, hotkey: None,
enabled: true, enabled: true,
page, page,
ui: None,
permission: None, permission: None,
settings: None, settings: None,
minimum_coco_version: None,
screenshots: None, screenshots: None,
url: None, url: None,
version: None, version: None,
@@ -385,28 +295,15 @@ 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_check_main_extension_only */
#[test] #[test]
fn test_group_cannot_have_alias() { fn test_group_cannot_have_alias() {
let mut extension = create_basic_extension("test-group", ExtensionType::Group); let mut extension = create_basic_extension("test-group", ExtensionType::Group);
extension.alias = Some("group-alias".to_string()); extension.alias = Some("group-alias".to_string());
expect_error_kind( let result = general_check(&extension);
general_check(&extension), assert!(result.is_err());
InvalidPluginJsonErrorKind::FieldsNotAllowed { assert!(result.unwrap_err().contains("cannot have alias"));
fields: &["alias"],
ty: ExtensionType::Group,
},
);
} }
#[test] #[test]
@@ -414,13 +311,9 @@ mod tests {
let mut extension = create_basic_extension("test-ext", ExtensionType::Extension); let mut extension = create_basic_extension("test-ext", ExtensionType::Extension);
extension.alias = Some("ext-alias".to_string()); extension.alias = Some("ext-alias".to_string());
expect_error_kind( let result = general_check(&extension);
general_check(&extension), assert!(result.is_err());
InvalidPluginJsonErrorKind::FieldsNotAllowed { assert!(result.unwrap_err().contains("cannot have alias"));
fields: &["alias"],
ty: ExtensionType::Extension,
},
);
} }
#[test] #[test]
@@ -428,13 +321,9 @@ mod tests {
let mut extension = create_basic_extension("test-group", ExtensionType::Group); let mut extension = create_basic_extension("test-group", ExtensionType::Group);
extension.hotkey = Some("cmd+g".to_string()); extension.hotkey = Some("cmd+g".to_string());
expect_error_kind( let result = general_check(&extension);
general_check(&extension), assert!(result.is_err());
InvalidPluginJsonErrorKind::FieldsNotAllowed { assert!(result.unwrap_err().contains("cannot have hotkey"));
fields: &["hotkey"],
ty: ExtensionType::Group,
},
);
} }
#[test] #[test]
@@ -442,13 +331,9 @@ mod tests {
let mut extension = create_basic_extension("test-ext", ExtensionType::Extension); let mut extension = create_basic_extension("test-ext", ExtensionType::Extension);
extension.hotkey = Some("cmd+e".to_string()); extension.hotkey = Some("cmd+e".to_string());
expect_error_kind( let result = general_check(&extension);
general_check(&extension), assert!(result.is_err());
InvalidPluginJsonErrorKind::FieldsNotAllowed { assert!(result.unwrap_err().contains("cannot have hotkey"));
fields: &["hotkey"],
ty: ExtensionType::Extension,
},
);
} }
#[test] #[test]
@@ -460,12 +345,12 @@ mod tests {
ExtensionType::Command, ExtensionType::Command,
)]); )]);
expect_error_kind( let result = general_check(&extension);
general_check(&extension), assert!(result.is_err());
InvalidPluginJsonErrorKind::FieldsNotAllowed { assert!(
fields: &["commands", "scripts", "quicklinks", "views"], result
ty: ExtensionType::Command, .unwrap_err()
}, .contains("only extension of type [Group] and [Extension] can have sub-extensions")
); );
} }
@@ -475,24 +360,20 @@ mod tests {
extension.settings = Some(ExtensionSettings { extension.settings = Some(ExtensionSettings {
hide_before_open: None, hide_before_open: None,
}); });
expect_error_kind( let error_msg = general_check(&extension).unwrap_err();
general_check(&extension), assert!(
InvalidPluginJsonErrorKind::FieldsNotAllowed { error_msg
fields: &["settings"], .contains("field [settings] is currently only allowed in searchable extension")
ty: ExtensionType::Group,
},
); );
let mut extension = create_basic_extension("test-extension", ExtensionType::Extension); let mut extension = create_basic_extension("test-extension", ExtensionType::Extension);
extension.settings = Some(ExtensionSettings { extension.settings = Some(ExtensionSettings {
hide_before_open: None, hide_before_open: None,
}); });
expect_error_kind( let error_msg = general_check(&extension).unwrap_err();
general_check(&extension), assert!(
InvalidPluginJsonErrorKind::FieldsNotAllowed { error_msg
fields: &["settings"], .contains("field [settings] is currently only allowed in searchable extension")
ty: ExtensionType::Extension,
},
); );
} }
/* test_check_main_extension_only */ /* test_check_main_extension_only */
@@ -502,12 +383,12 @@ mod tests {
fn test_command_must_have_action() { fn test_command_must_have_action() {
let extension = create_basic_extension("test-cmd", ExtensionType::Command); let extension = create_basic_extension("test-cmd", ExtensionType::Command);
expect_error_kind( let result = general_check(&extension);
general_check(&extension), assert!(result.is_err());
InvalidPluginJsonErrorKind::FieldRequired { assert!(
field: "action", result
ty: ExtensionType::Command, .unwrap_err()
}, .contains("field [action] should be set for a Command extension")
); );
} }
@@ -516,12 +397,12 @@ mod tests {
let mut extension = create_basic_extension("test-script", ExtensionType::Script); let mut extension = create_basic_extension("test-script", ExtensionType::Script);
extension.action = Some(create_command_action()); extension.action = Some(create_command_action());
expect_error_kind( let result = general_check(&extension);
general_check(&extension), assert!(result.is_err());
InvalidPluginJsonErrorKind::FieldsNotAllowed { assert!(
fields: &["action"], result
ty: ExtensionType::Script, .unwrap_err()
}, .contains("field [action] is set for a non-Command extension")
); );
} }
@@ -529,12 +410,12 @@ mod tests {
fn test_quicklink_must_have_quicklink_field() { fn test_quicklink_must_have_quicklink_field() {
let extension = create_basic_extension("test-quicklink", ExtensionType::Quicklink); let extension = create_basic_extension("test-quicklink", ExtensionType::Quicklink);
expect_error_kind( let result = general_check(&extension);
general_check(&extension), assert!(result.is_err());
InvalidPluginJsonErrorKind::FieldRequired { assert!(
field: "quicklink", result
ty: ExtensionType::Quicklink, .unwrap_err()
}, .contains("field [quicklink] should be set for a Quicklink extension")
); );
} }
@@ -544,12 +425,12 @@ mod tests {
extension.action = Some(create_command_action()); extension.action = Some(create_command_action());
extension.quicklink = Some(create_quicklink()); extension.quicklink = Some(create_quicklink());
expect_error_kind( let result = general_check(&extension);
general_check(&extension), assert!(result.is_err());
InvalidPluginJsonErrorKind::FieldsNotAllowed { assert!(
fields: &["quicklink"], result
ty: ExtensionType::Command, .unwrap_err()
}, .contains("field [quicklink] is set for a non-Quicklink extension")
); );
} }
@@ -559,12 +440,12 @@ mod tests {
// create_basic_extension() will set its page field if type is View, clear it // create_basic_extension() will set its page field if type is View, clear it
extension.page = None; extension.page = None;
expect_error_kind( let result = general_check(&extension);
general_check(&extension), assert!(result.is_err());
InvalidPluginJsonErrorKind::FieldRequired { assert!(
field: "page", result
ty: ExtensionType::View, .unwrap_err()
}, .contains("field [page] should be set for a View extension")
); );
} }
@@ -574,12 +455,12 @@ mod tests {
extension.action = Some(create_command_action()); extension.action = Some(create_command_action());
extension.page = Some("index.html".into()); extension.page = Some("index.html".into());
expect_error_kind( let result = general_check(&extension);
general_check(&extension), assert!(result.is_err());
InvalidPluginJsonErrorKind::FieldsNotAllowed { assert!(
fields: &["page"], result
ty: ExtensionType::Command, .unwrap_err()
}, .contains("field [page] is set for a non-View extension")
); );
} }
/* test check_main_extension_or_sub_extension */ /* test check_main_extension_or_sub_extension */
@@ -591,11 +472,12 @@ mod tests {
let sub_group = create_basic_extension("sub-group", ExtensionType::Group); let sub_group = create_basic_extension("sub-group", ExtensionType::Group);
extension.commands = Some(vec![sub_group]); extension.commands = Some(vec![sub_group]);
expect_error_kind( let result = general_check(&extension);
general_check(&extension), assert!(result.is_err());
InvalidPluginJsonErrorKind::TypesNotAllowedForSubExtension { assert!(
types: &[ExtensionType::Group, ExtensionType::Extension], result
}, .unwrap_err()
.contains("sub-extensions should not be of type [Group] or [Extension]")
); );
} }
@@ -605,11 +487,12 @@ mod tests {
let sub_ext = create_basic_extension("sub-ext", ExtensionType::Extension); let sub_ext = create_basic_extension("sub-ext", ExtensionType::Extension);
extension.scripts = Some(vec![sub_ext]); extension.scripts = Some(vec![sub_ext]);
expect_error_kind( let result = general_check(&extension);
general_check(&extension), assert!(result.is_err());
InvalidPluginJsonErrorKind::TypesNotAllowedForSubExtension { assert!(
types: &[ExtensionType::Group, ExtensionType::Extension], result
}, .unwrap_err()
.contains("sub-extensions should not be of type [Group] or [Extension]")
); );
} }
@@ -622,11 +505,12 @@ mod tests {
extension.commands = Some(vec![sub_cmd]); extension.commands = Some(vec![sub_cmd]);
expect_error_kind( let result = general_check(&extension);
general_check(&extension), assert!(result.is_err());
InvalidPluginJsonErrorKind::FieldsNotAllowedForSubExtension { assert!(
fields: &["developer"], result
}, .unwrap_err()
.contains("field [developer] should not be set in sub-extensions")
); );
} }
@@ -642,27 +526,11 @@ mod tests {
extension.commands = Some(vec![sub_cmd]); extension.commands = Some(vec![sub_cmd]);
expect_error_kind( let result = general_check(&extension);
general_check(&extension), assert!(result.is_err());
InvalidPluginJsonErrorKind::FieldsNotAllowedForSubExtension { assert!(result.unwrap_err().contains(
fields: &["commands", "scripts", "quicklinks", "views"], "fields [commands/scripts/quicklinks/views] should not be set in sub-extensions"
}, ));
);
}
#[test]
fn test_sub_extension_cannot_set_minimum_coco_version() {
let mut extension = create_basic_extension("test-group", ExtensionType::Group);
let mut sub_cmd = create_basic_extension("sub-cmd", ExtensionType::Command);
sub_cmd.minimum_coco_version = Some(semver::Version::new(0, 8, 0));
extension.commands = Some(vec![sub_cmd]);
expect_error_kind(
general_check(&extension),
InvalidPluginJsonErrorKind::FieldsNotAllowedForSubExtension {
fields: &["minimum_coco_version"],
},
);
} }
/* Test check_sub_extension_only */ /* Test check_sub_extension_only */
@@ -678,11 +546,12 @@ mod tests {
extension.commands = Some(vec![cmd1, cmd2]); extension.commands = Some(vec![cmd1, cmd2]);
expect_error_kind( let result = general_check(&extension);
general_check(&extension), assert!(result.is_err());
InvalidPluginJsonErrorKind::DuplicateSubExtensionId { assert!(
id: "duplicate-id".to_string(), result
}, .unwrap_err()
.contains("sub-extension with ID [duplicate-id] already exists")
); );
} }
@@ -698,11 +567,12 @@ mod tests {
extension.commands = Some(vec![cmd]); extension.commands = Some(vec![cmd]);
extension.scripts = Some(vec![script]); extension.scripts = Some(vec![script]);
expect_error_kind( let result = general_check(&extension);
general_check(&extension), assert!(result.is_err());
InvalidPluginJsonErrorKind::DuplicateSubExtensionId { assert!(
id: "same-id".to_string(), result
}, .unwrap_err()
.contains("sub-extension with ID [same-id] already exists")
); );
} }
@@ -865,12 +735,12 @@ mod tests {
main_extension.commands = Some(vec![sub_cmd]); main_extension.commands = Some(vec![sub_cmd]);
expect_error_kind( let result = general_check(&main_extension);
general_check(&main_extension), assert!(result.is_err());
InvalidPluginJsonErrorKind::SubExtensionHasMoreSupportedPlatforms { let error_msg = result.unwrap_err();
extra_platforms: vec!["Linux".to_string()], assert!(error_msg.contains("it supports platforms"));
}, assert!(error_msg.contains("that are not supported by the main extension"));
); assert!(error_msg.contains("Linux")); // Should mention the unsupported platform
} }
#[test] #[test]
@@ -886,12 +756,12 @@ mod tests {
main_extension.commands = Some(vec![sub_cmd]); main_extension.commands = Some(vec![sub_cmd]);
expect_error_kind( let result = general_check(&main_extension);
general_check(&main_extension), assert!(result.is_err());
InvalidPluginJsonErrorKind::SubExtensionHasMoreSupportedPlatforms { let error_msg = result.unwrap_err();
extra_platforms: vec!["Linux".to_string()], assert!(error_msg.contains("it supports platforms"));
}, assert!(error_msg.contains("that are not supported by the main extension"));
); assert!(error_msg.contains("Linux")); // Should mention the unsupported platform
} }
#[test] #[test]

View File

@@ -1,74 +0,0 @@
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,17 +1,6 @@
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::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::{ use crate::extension::third_party::install::{
filter_out_incompatible_sub_extensions, is_extension_installed, convert_page, filter_out_incompatible_sub_extensions, is_extension_installed,
}; };
use crate::extension::third_party::{ use crate::extension::third_party::{
THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE, get_third_party_extension_directory, THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE, get_third_party_extension_directory,
@@ -19,11 +8,9 @@ use crate::extension::third_party::{
use crate::extension::{ use crate::extension::{
Extension, canonicalize_relative_icon_path, canonicalize_relative_page_path, Extension, canonicalize_relative_icon_path, canonicalize_relative_page_path,
}; };
use crate::extension::{ExtensionType, PLUGIN_JSON_FILE_NAME};
use crate::util::platform::Platform; use crate::util::platform::Platform;
use serde_json::Value as Json; 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::Path;
use std::path::PathBuf; use std::path::PathBuf;
use tauri::AppHandle; use tauri::AppHandle;
@@ -48,54 +35,49 @@ const DEVELOPER_ID_LOCAL: &str = "__local__";
pub(crate) async fn install_local_extension( pub(crate) async fn install_local_extension(
tauri_app_handle: AppHandle, tauri_app_handle: AppHandle,
path: PathBuf, path: PathBuf,
) -> Result<(), InstallExtensionError> { ) -> Result<(), String> {
let extension_dir_name = path let extension_dir_name = path
.file_name() .file_name()
.ok_or_else(|| InvalidExtensionError::NoFileName { path: path.clone() }) .ok_or_else(|| "Invalid extension: no directory name".to_string())?
.context(InvalidExtensionSnafu)?
.to_str() .to_str()
.ok_or_else(|| InvalidExtensionError::NonUtf8Encoding { .ok_or_else(|| "Invalid extension: non-UTF8 extension id".to_string())?;
os_str: path.clone().into_os_string(),
})
.context(InvalidExtensionSnafu)?;
// we use extension directory name as the extension ID. // we use extension directory name as the extension ID.
let extension_id = extension_dir_name; let extension_id = extension_dir_name;
if is_extension_installed(DEVELOPER_ID_LOCAL, extension_id).await { if is_extension_installed(DEVELOPER_ID_LOCAL, extension_id).await {
return Err(InstallExtensionError::AlreadyInstalled); // The frontend code uses this string to distinguish between 2 error cases:
//
// 1. This extension is already imported
// 2. This extension is incompatible with the current platform
// 3. The selected directory does not contain a valid extension
//
// do NOT edit this without updating the frontend code.
//
// ```ts
// if (errorMessage === "already imported") {
// addError(t("settings.extensions.hints.extensionAlreadyImported"));
// } else if (errorMessage === "incompatible") {
// addError(t("settings.extensions.hints.incompatibleExtension"));
// } else {
// addError(t("settings.extensions.hints.importFailed"));
// }
// ```
//
// This is definitely error-prone, but we have to do this until we have
// structured error type
return Err("already imported".into());
} }
let plugin_json_path = path.join(PLUGIN_JSON_FILE_NAME); let plugin_json_path = path.join(PLUGIN_JSON_FILE_NAME);
let plugin_json_content = match fs::read_to_string(&plugin_json_path).await { let plugin_json_content = fs::read_to_string(&plugin_json_path)
Ok(content) => content, .await
Err(io_err) => { .map_err(|e| e.to_string())?;
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 // 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`: // 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) let mut extension_json: Json =
.context(DecodePluginJsonSnafu) serde_json::from_str(&plugin_json_content).map_err(|e| e.to_string())?;
.context(InvalidExtensionSnafu)?;
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 // Set the main extension ID to the directory name
let extension_obj = extension_json let extension_obj = extension_json
@@ -147,22 +129,36 @@ pub(crate) async fn install_local_extension(
} }
// Now we can convert JSON to `struct Extension` // Now we can convert JSON to `struct Extension`
let mut extension: Extension = serde_json::from_value(extension_json) let mut extension: Extension =
.context(DecodePluginJsonSnafu) serde_json::from_value(extension_json).map_err(|e| e.to_string())?;
.context(InvalidExtensionSnafu)?;
let current_platform = Platform::current(); let current_platform = Platform::current();
/* Check begins here */ /* Check begins here */
general_check(&extension) general_check(&extension)?;
.context(InvalidPluginJsonSnafu)
.context(InvalidExtensionSnafu)?;
if let Some(ref platforms) = extension.platforms { if let Some(ref platforms) = extension.platforms {
if !platforms.contains(&current_platform) { if !platforms.contains(&current_platform) {
return Err(InstallExtensionError::IncompatiblePlatform { // The frontend code uses this string to distinguish between 3 error cases:
current_platform, //
compatible_platforms: platforms.clone(), // 1. This extension is already imported
}); // 2. This extension is incompatible with the current platform
// 3. The selected directory does not contain a valid extension
//
// do NOT edit this without updating the frontend code.
//
// ```ts
// if (errorMessage === "already imported") {
// addError(t("settings.extensions.hints.extensionAlreadyImported"));
// } else if (errorMessage === "incompatible") {
// addError(t("settings.extensions.hints.incompatibleExtension"));
// } else {
// addError(t("settings.extensions.hints.importFailed"));
// }
// ```
//
// This is definitely error-prone, but we have to do this until we have
// structured error type
return Err("incompatible".into());
} }
} }
/* Check ends here */ /* Check ends here */
@@ -184,19 +180,18 @@ pub(crate) async fn install_local_extension(
.join(DEVELOPER_ID_LOCAL) .join(DEVELOPER_ID_LOCAL)
.join(extension_dir_name); .join(extension_dir_name);
fs::create_dir_all(&dest_dir).await.context(IoSnafu)?; fs::create_dir_all(&dest_dir)
.await
.map_err(|e| e.to_string())?;
// Copy all files except plugin.json // Copy all files except plugin.json
let mut entries = fs::read_dir(&path).await.context(IoSnafu)?; let mut entries = fs::read_dir(&path).await.map_err(|e| e.to_string())?;
while let Some(entry) = entries.next_entry().await.context(IoSnafu)? { while let Some(entry) = entries.next_entry().await.map_err(|e| e.to_string())? {
let file_name = entry.file_name(); let file_name = entry.file_name();
let file_name_str = file_name let file_name_str = file_name
.to_str() .to_str()
.ok_or_else(|| InvalidExtensionError::NonUtf8Encoding { .ok_or_else(|| "Invalid filename: non-UTF8".to_string())?;
os_str: file_name.clone(),
})
.context(InvalidExtensionSnafu)?;
// plugin.json will be handled separately. // plugin.json will be handled separately.
if file_name_str == PLUGIN_JSON_FILE_NAME { if file_name_str == PLUGIN_JSON_FILE_NAME {
@@ -208,32 +203,74 @@ pub(crate) async fn install_local_extension(
if src_path.is_dir() { if src_path.is_dir() {
// Recursively copy directory // Recursively copy directory
copy_dir_recursively(&src_path, &dest_path) copy_dir_recursively(&src_path, &dest_path).await?;
.await
.context(IoSnafu)?;
} else { } else {
// Copy file // Copy file
fs::copy(&src_path, &dest_path).await.context(IoSnafu)?; fs::copy(&src_path, &dest_path)
.await
.map_err(|e| e.to_string())?;
} }
} }
// Write the corrected plugin.json file // Write the corrected plugin.json file
let corrected_plugin_json = serde_json::to_string_pretty(&extension).unwrap_or_else(|e| { let corrected_plugin_json =
panic!( serde_json::to_string_pretty(&extension).map_err(|e| e.to_string())?;
"failed to serialize extension {:?}, error:\n{}",
extension,
report_error(&e, ReportErrorStyle::MultipleLines)
)
});
let dest_plugin_json_path = dest_dir.join(PLUGIN_JSON_FILE_NAME); let dest_plugin_json_path = dest_dir.join(PLUGIN_JSON_FILE_NAME);
fs::write(&dest_plugin_json_path, corrected_plugin_json) fs::write(&dest_plugin_json_path, corrected_plugin_json)
.await .await
.context(IoSnafu)?; .map_err(|e| e.to_string())?;
/*
* Call convert_page() to update the page files. This has to be done after
* writing the extension files
*/
let absolute_page_paths: Vec<PathBuf> = {
fn canonicalize_page_path(page_path: &Path, extension_root: &Path) -> PathBuf {
if page_path.is_relative() {
// It is relative to the extension root directory
extension_root.join(page_path)
} else {
page_path.into()
}
}
if extension.r#type == ExtensionType::View {
let page = extension
.page
.as_ref()
.expect("View extension should set its page field");
let path = canonicalize_page_path(Path::new(page.as_str()), &dest_dir);
vec![path]
} else if extension.r#type.contains_sub_items()
&& let Some(ref views) = extension.views
{
let mut paths = Vec::with_capacity(views.len());
for view in views.iter() {
let page = view
.page
.as_ref()
.expect("View extension should set its page field");
let path = canonicalize_page_path(Path::new(page.as_str()), &dest_dir);
paths.push(path);
}
paths
} else {
// No pages in this extension
Vec::new()
}
};
for page_path in absolute_page_paths {
convert_page(&page_path).await?;
}
// Canonicalize relative icon and page paths // Canonicalize relative icon and page paths
canonicalize_relative_icon_path(&dest_dir, &mut extension).context(IoSnafu)?; canonicalize_relative_icon_path(&dest_dir, &mut extension)?;
canonicalize_relative_page_path(&dest_dir, &mut extension).context(IoSnafu)?; canonicalize_relative_page_path(&dest_dir, &mut extension)?;
// Add extension to the search source // Add extension to the search source
third_party_ext_list_write_lock.push(extension); third_party_ext_list_write_lock.push(extension);
@@ -243,18 +280,22 @@ pub(crate) async fn install_local_extension(
/// Helper function to recursively copy directories. /// Helper function to recursively copy directories.
#[async_recursion::async_recursion] #[async_recursion::async_recursion]
async fn copy_dir_recursively(src: &Path, dest: &Path) -> Result<(), io::Error> { async fn copy_dir_recursively(src: &Path, dest: &Path) -> Result<(), String> {
tokio::fs::create_dir_all(dest).await?; tokio::fs::create_dir_all(dest)
let mut read_dir = tokio::fs::read_dir(src).await?; .await
.map_err(|e| e.to_string())?;
let mut read_dir = tokio::fs::read_dir(src).await.map_err(|e| e.to_string())?;
while let Some(entry) = read_dir.next_entry().await? { while let Some(entry) = read_dir.next_entry().await.map_err(|e| e.to_string())? {
let src_path = entry.path(); let src_path = entry.path();
let dest_path = dest.join(entry.file_name()); let dest_path = dest.join(entry.file_name());
if src_path.is_dir() { if src_path.is_dir() {
copy_dir_recursively(&src_path, &dest_path).await?; copy_dir_recursively(&src_path, &dest_path).await?;
} else { } else {
tokio::fs::copy(&src_path, &dest_path).await?; tokio::fs::copy(&src_path, &dest_path)
.await
.map_err(|e| e.to_string())?;
} }
} }

View File

@@ -4,27 +4,19 @@
//! # How //! # How
//! //!
//! Technically, installing an extension involves the following steps. The order //! Technically, installing an extension involves the following steps. The order
//! varies between 2 implementations. //! may vary between implementations.
//! //!
//! 1. Check if it is already installed, if so, return //! 1. Check if it is already installed, if so, return
//! //!
//! 2. Check if it is compatible by inspecting the "minimum_coco_version" //! 2. Correct the `plugin.json` JSON if it does not conform to our `struct
//! field. If it is incompatible, reject and error out.
//!
//! This should be done before convert `plugin.json` JSON to `struct Extension`
//! as the definition of `struct Extension` could change in the future, in this
//! case, we want to tell users that "it is an incompatible extension" rather
//! than "this extension is invalid".
//!
//! 3. Correct the `plugin.json` JSON if it does not conform to our `struct
//! Extension` definition. This can happen because the JSON written by //! Extension` definition. This can happen because the JSON written by
//! developers is in a simplified form for a better developer experience. //! developers is in a simplified form for a better developer experience.
//! //!
//! 4. Validate the corrected `plugin.json` //! 3. Validate the corrected `plugin.json`
//! 1. misc checks //! 1. misc checks
//! 2. Platform compatibility check //! 2. Platform compatibility check
//! //!
//! 5. Write the extension files to the corresponding location //! 4. Write the extension files to the corresponding location
//! //!
//! * developer directory //! * developer directory
//! * extension directory //! * extension directory
@@ -33,6 +25,11 @@
//! * plugin.json file //! * plugin.json file
//! * View pages if exist //! * View pages if exist
//! //!
//! 5. If this extension contains any View extensions, call `convert_page()`
//! on them to make them loadable by Tauri/webview.
//!
//! See `convert_page()` for more info.
//!
//! 6. Canonicalize `Extension.icon` and `Extension.page` fields if they are //! 6. Canonicalize `Extension.icon` and `Extension.page` fields if they are
//! relative paths //! relative paths
//! //!
@@ -41,19 +38,12 @@
//! //!
//! 7. Add the extension to the in-memory extension list. //! 7. Add the extension to the in-memory extension list.
pub(crate) mod error;
pub(crate) mod local_extension; pub(crate) mod local_extension;
pub(crate) mod store; pub(crate) mod store;
use crate::extension::Extension; use crate::extension::Extension;
use crate::extension::PLUGIN_JSON_FIELD_MINIMUM_COCO_VERSION;
use crate::util::platform::Platform; use crate::util::platform::Platform;
use crate::util::version::ParseVersionError; use std::path::Path;
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; use super::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE;
@@ -125,31 +115,117 @@ pub(crate) fn filter_out_incompatible_sub_extensions(
} }
} }
#[derive(Debug, Snafu, Serialize)] /// Convert the page file to make it loadable by the Tauri/Webview.
pub(crate) enum ParsingMinimumCocoVersionError { pub(crate) async fn convert_page(absolute_page_path: &Path) -> Result<(), String> {
#[snafu(display("field 'minimum_coco_version' should be a string, but it is not"))] assert!(absolute_page_path.is_absolute());
MismatchType,
#[snafu(display("failed to parse field 'minimum_coco_version'"))] let page_content = tokio::fs::read_to_string(absolute_page_path)
ParsingVersionError { source: ParseVersionError }, .await
.map_err(|e| e.to_string())?;
let new_page_content = _convert_page(&page_content, absolute_page_path)?;
// overwrite it
tokio::fs::write(absolute_page_path, new_page_content)
.await
.map_err(|e| e.to_string())?;
Ok(())
} }
/// Inspect the "minimum_coco_version" field and see if this extension is /// NOTE: There is no Rust implementation of `convertFileSrc()` in Tauri. Our
/// compatible with the current Coco app. /// impl here is based on [comment](https://github.com/tauri-apps/tauri/issues/12022#issuecomment-2572879115)
fn check_compatibility_via_mcv(plugin_json: &Json) -> Result<bool, ParsingMinimumCocoVersionError> { fn convert_file_src(path: &Path) -> Result<String, String> {
let Some(mcv_json) = plugin_json.get(PLUGIN_JSON_FIELD_MINIMUM_COCO_VERSION) else { #[cfg(any(windows, target_os = "android"))]
return Ok(true); let base = "http://asset.localhost/";
}; #[cfg(not(any(windows, target_os = "android")))]
if mcv_json == &Json::Null { let base = "asset://localhost/";
return Ok(true);
let path =
dunce::canonicalize(path).map_err(|e| format!("Failed to canonicalize path: {}", e))?;
let path_str = path.to_string_lossy();
let encoded = urlencoding::encode(&path_str);
Ok(format!("{base}{encoded}"))
} }
let Some(mcv_str) = mcv_json.as_str() else { /// Tauri cannot directly access the file system, to make a file loadable, we
return Err(ParsingMinimumCocoVersionError::MismatchType); /// have to `canonicalize()` and `convertFileSrc()` its path before passing it
}; /// to Tauri.
///
/// View extension's page is a HTML file that Coco (Tauri) will load, we need
/// to process all `<PATH>` tags:
///
/// 1. `<script type="xxx" crossorigin src="<PATH>"></script>`
/// 2. `<a href="<PATH>">xxx</a>`
/// 3. `<link rel="xxx" href="<PATH>"/>`
/// 4. `<img class="xxx" src="<PATH>" alt="xxx"/>`
fn _convert_page(page_content: &str, absolute_page_path: &Path) -> Result<String, String> {
use scraper::{Html, Selector};
let mcv = parse_coco_semver(mcv_str).context(ParsingVersionSnafu)?; /// Helper function.
///
/// Search `document` for the tag attributes specified by `tag_with_attribute`
/// and `tag_attribute`, call `convert_file_src()`, then update the attribute
/// value with the function return value.
fn modify_tag_attributes(
document: &Html,
modified_html: &mut String,
base_dir: &Path,
tag_with_attribute: &str,
tag_attribute: &str,
) -> Result<(), String> {
let script_selector = Selector::parse(tag_with_attribute).unwrap();
for element in document.select(&script_selector) {
if let Some(src) = element.value().attr(tag_attribute) {
if !src.starts_with("http://")
&& !src.starts_with("https://")
&& !src.starts_with("asset://")
&& !src.starts_with("http://asset.localhost/")
{
// It could be a path like "/assets/index-41be3ec9.js", but it
// is still a relative path. We need to remove the starting /
// or path.join() will think it is an absolute path and does nothing
let corrected_src = if src.starts_with('/') { &src[1..] } else { src };
Ok(COCO_VERSION.deref() >= &mcv) let full_path = base_dir.join(corrected_src);
let converted_path = convert_file_src(full_path.as_path())?;
*modified_html = modified_html.replace(
&format!("{}=\"{}\"", tag_attribute, src),
&format!("{}=\"{}\"", tag_attribute, converted_path),
);
}
}
}
Ok(())
}
let base_dir = absolute_page_path
.parent()
.ok_or_else(|| format!("page path is invalid, it should have a parent path"))?;
let document: Html = Html::parse_document(page_content);
let mut modified_html: String = page_content.to_string();
modify_tag_attributes(
&document,
&mut modified_html,
base_dir,
"script[src]",
"src",
)?;
modify_tag_attributes(&document, &mut modified_html, base_dir, "a[href]", "href")?;
modify_tag_attributes(
&document,
&mut modified_html,
base_dir,
"link[href]",
"href",
)?;
modify_tag_attributes(&document, &mut modified_html, base_dir, "img[src]", "src")?;
Ok(modified_html)
} }
#[cfg(test)] #[cfg(test)]
@@ -183,8 +259,6 @@ mod tests {
enabled: true, enabled: true,
settings: None, settings: None,
page: None, page: None,
ui: None,
minimum_coco_version: None,
permission: None, permission: None,
screenshots: None, screenshots: None,
url: None, url: None,
@@ -354,4 +428,257 @@ mod tests {
filter_out_incompatible_sub_extensions(&mut main_extension, Platform::Macos); filter_out_incompatible_sub_extensions(&mut main_extension, Platform::Macos);
assert_eq!(main_extension.views.unwrap().len(), 1); assert_eq!(main_extension.views.unwrap().len(), 1);
} }
#[test]
fn test_convert_page_script_tag() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let html_file = temp_dir.path().join("test.html");
let js_file = temp_dir.path().join("main.js");
let html_content = r#"<html><body><script src="main.js"></script></body></html>"#;
std::fs::write(&html_file, html_content).unwrap();
std::fs::write(&js_file, "").unwrap();
let result = _convert_page(html_content, &html_file).unwrap();
let path = convert_file_src(&js_file).unwrap();
let expected = format!(
"<html><body><script src=\"{}\"></script></body></html>",
path
);
assert_eq!(result, expected);
}
#[test]
fn test_convert_page_script_tag_with_a_root_char() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let html_file = temp_dir.path().join("test.html");
let js_file = temp_dir.path().join("main.js");
let html_content = r#"<html><body><script src="/main.js"></script></body></html>"#;
std::fs::write(&html_file, html_content).unwrap();
std::fs::write(&js_file, "").unwrap();
let result = _convert_page(html_content, &html_file).unwrap();
let path = convert_file_src(&js_file).unwrap();
let expected = format!(
"<html><body><script src=\"{}\"></script></body></html>",
path
);
assert_eq!(result, expected);
}
#[test]
fn test_convert_page_a_tag() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let html_file = temp_dir.path().join("test.html");
let js_file = temp_dir.path().join("main.js");
let html_content = r#"<html><body><a href="main.js">foo</a></body></html>"#;
std::fs::write(&html_file, html_content).unwrap();
std::fs::write(&js_file, "").unwrap();
let result = _convert_page(html_content, &html_file).unwrap();
let path = convert_file_src(&js_file).unwrap();
let expected = format!("<html><body><a href=\"{}\">foo</a></body></html>", path);
assert_eq!(result, expected);
}
#[test]
fn test_convert_page_a_tag_with_a_root_char() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let html_file = temp_dir.path().join("test.html");
let js_file = temp_dir.path().join("main.js");
let html_content = r#"<html><body><a href="/main.js">foo</a></body></html>"#;
std::fs::write(&html_file, html_content).unwrap();
std::fs::write(&js_file, "").unwrap();
let result = _convert_page(html_content, &html_file).unwrap();
let path = convert_file_src(&js_file).unwrap();
let expected = format!("<html><body><a href=\"{}\">foo</a></body></html>", path);
assert_eq!(result, expected);
}
#[test]
fn test_convert_page_link_href_tag() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let html_file = temp_dir.path().join("test.html");
let css_file = temp_dir.path().join("main.css");
let html_content = r#"<html><body><link rel="stylesheet" href="main.css"/></body></html>"#;
std::fs::write(&html_file, html_content).unwrap();
std::fs::write(&css_file, "").unwrap();
let result = _convert_page(html_content, &html_file).unwrap();
let path = convert_file_src(&css_file).unwrap();
let expected = format!(
"<html><body><link rel=\"stylesheet\" href=\"{}\"/></body></html>",
path
);
assert_eq!(result, expected);
}
#[test]
fn test_convert_page_link_href_tag_with_a_root_tag() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let html_file = temp_dir.path().join("test.html");
let css_file = temp_dir.path().join("main.css");
let html_content = r#"<html><body><link rel="stylesheet" href="/main.css"/></body></html>"#;
std::fs::write(&html_file, html_content).unwrap();
std::fs::write(&css_file, "").unwrap();
let result = _convert_page(html_content, &html_file).unwrap();
let path = convert_file_src(&css_file).unwrap();
let expected = format!(
"<html><body><link rel=\"stylesheet\" href=\"{}\"/></body></html>",
path
);
assert_eq!(result, expected);
}
#[test]
fn test_convert_page_img_src_tag() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let html_file = temp_dir.path().join("test.html");
let png_file = temp_dir.path().join("main.png");
let html_content =
r#"<html><body> <img class="fit-picture" src="main.png" alt="xxx" /></body></html>"#;
std::fs::write(&html_file, html_content).unwrap();
std::fs::write(&png_file, "").unwrap();
let result = _convert_page(html_content, &html_file).unwrap();
let path = convert_file_src(&png_file).unwrap();
let expected = format!(
"<html><body> <img class=\"fit-picture\" src=\"{}\" alt=\"xxx\" /></body></html>",
path
);
assert_eq!(result, expected);
}
#[test]
fn test_convert_page_img_src_tag_with_a_root_tag() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let html_file = temp_dir.path().join("test.html");
let png_file = temp_dir.path().join("main.png");
let html_content =
r#"<html><body> <img class="fit-picture" src="/main.png" alt="xxx" /></body></html>"#;
std::fs::write(&html_file, html_content).unwrap();
std::fs::write(&png_file, "").unwrap();
let result = _convert_page(html_content, &html_file).unwrap();
let path = convert_file_src(&png_file).unwrap();
let expected = format!(
"<html><body> <img class=\"fit-picture\" src=\"{}\" alt=\"xxx\" /></body></html>",
path
);
assert_eq!(result, expected);
}
#[test]
fn test_convert_page_contain_both_script_and_a_tags() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let html_file = temp_dir.path().join("test.html");
let js_file = temp_dir.path().join("main.js");
let html_content =
r#"<html><body><a href="main.js">foo</a><script src="main.js"></script></body></html>"#;
std::fs::write(&html_file, html_content).unwrap();
std::fs::write(&js_file, "").unwrap();
let result = _convert_page(html_content, &html_file).unwrap();
let path = convert_file_src(&js_file).unwrap();
let expected = format!(
"<html><body><a href=\"{}\">foo</a><script src=\"{}\"></script></body></html>",
path, path
);
assert_eq!(result, expected);
}
#[test]
fn test_convert_page_contain_both_script_and_a_tags_with_root_char() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let html_file = temp_dir.path().join("test.html");
let js_file = temp_dir.path().join("main.js");
let html_content = r#"<html><body><a href="/main.js">foo</a><script src="/main.js"></script></body></html>"#;
std::fs::write(&html_file, html_content).unwrap();
std::fs::write(&js_file, "").unwrap();
let result = _convert_page(html_content, &html_file).unwrap();
let path = convert_file_src(&js_file).unwrap();
let expected = format!(
"<html><body><a href=\"{}\">foo</a><script src=\"{}\"></script></body></html>",
path, path
);
assert_eq!(result, expected);
}
#[test]
fn test_convert_page_empty_html() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let html_file = temp_dir.path().join("test.html");
let html_content = "";
std::fs::write(&html_file, html_content).unwrap();
let result = _convert_page(html_content, &html_file).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_convert_page_only_html_tag() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let html_file = temp_dir.path().join("test.html");
let html_content = "<html></html>";
std::fs::write(&html_file, html_content).unwrap();
let result = _convert_page(html_content, &html_file).unwrap();
assert_eq!(result, html_content);
}
} }

View File

@@ -1,43 +1,33 @@
//! Extension store related stuff. //! Extension store related stuff.
use super::super::LOCAL_QUERY_SOURCE_TYPE; use super::super::LOCAL_QUERY_SOURCE_TYPE;
use super::check_compatibility_via_mcv;
use super::is_extension_installed; use super::is_extension_installed;
use crate::common::document::DataSourceReference; use crate::common::document::DataSourceReference;
use crate::common::document::Document; use crate::common::document::Document;
use crate::common::error::ReportErrorStyle;
use crate::common::error::SearchError; use crate::common::error::SearchError;
use crate::common::error::report_error;
use crate::common::search::QueryResponse; use crate::common::search::QueryResponse;
use crate::common::search::QuerySource; use crate::common::search::QuerySource;
use crate::common::search::SearchQuery; use crate::common::search::SearchQuery;
use crate::common::traits::SearchSource; use crate::common::traits::SearchSource;
use crate::extension::Extension; use crate::extension::Extension;
use crate::extension::ExtensionType;
use crate::extension::PLUGIN_JSON_FILE_NAME; use crate::extension::PLUGIN_JSON_FILE_NAME;
use crate::extension::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE; use crate::extension::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE;
use crate::extension::canonicalize_relative_icon_path; use crate::extension::canonicalize_relative_icon_path;
use crate::extension::canonicalize_relative_page_path; use crate::extension::canonicalize_relative_page_path;
use crate::extension::third_party::check::general_check; use crate::extension::third_party::check::general_check;
use crate::extension::third_party::get_third_party_extension_directory; use crate::extension::third_party::get_third_party_extension_directory;
use crate::extension::third_party::install::error::DecodePluginJsonSnafu; use crate::extension::third_party::install::convert_page;
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::extension::third_party::install::filter_out_incompatible_sub_extensions;
use crate::server::http_client::DecodeResponseSnafu;
use crate::server::http_client::HttpClient; use crate::server::http_client::HttpClient;
use crate::util::platform::Platform; use crate::util::platform::Platform;
use async_trait::async_trait; use async_trait::async_trait;
use reqwest::StatusCode; use reqwest::StatusCode;
use serde_json::Map as JsonObject; use serde_json::Map as JsonObject;
use serde_json::Value as Json; use serde_json::Value as Json;
use snafu::ResultExt;
use std::io::Read; use std::io::Read;
use std::path::Path;
use std::path::PathBuf;
use tauri::AppHandle; use tauri::AppHandle;
const DATA_SOURCE_ID: &str = "Extension Store"; const DATA_SOURCE_ID: &str = "Extension Store";
@@ -117,23 +107,15 @@ pub(crate) async fn search_extension(
.await .await
.map_err(|e| format!("Failed to send request: {:?}", e))?; .map_err(|e| format!("Failed to send request: {:?}", e))?;
if response.status() == StatusCode::NOT_FOUND {
return Ok(Vec::new());
}
// The response of a ES style search request // The response of a ES style search request
let mut response: JsonObject<String, Json> = response let mut response: JsonObject<String, Json> = response
.json() .json()
.await .await
.map_err(|e| format!("Failed to parse response: {:?}", e))?; .map_err(|e| format!("Failed to parse response: {:?}", e))?;
let hits_json = response.remove("hits").unwrap_or_else(|| { let hits_json = response
panic!( .remove("hits")
"the JSON response should contain field [hits], response [{:?}]", .expect("the JSON response should contain field [hits]");
response
)
});
let mut hits = match hits_json { let mut hits = match hits_json {
Json::Object(obj) => obj, Json::Object(obj) => obj,
_ => panic!( _ => panic!(
@@ -246,24 +228,24 @@ pub(crate) async fn extension_detail(
pub(crate) async fn install_extension_from_store( pub(crate) async fn install_extension_from_store(
tauri_app_handle: AppHandle, tauri_app_handle: AppHandle,
id: String, id: String,
) -> Result<(), InstallExtensionError> { ) -> Result<(), String> {
let path = format!("store/extension/{}/_download", id); let path = format!("store/extension/{}/_download", id);
let response = HttpClient::get("default_coco_server", &path, None) let response = HttpClient::get("default_coco_server", &path, None)
.await .await
.context(DownloadFailureSnafu)?; .map_err(|e| format!("Failed to download extension: {}", e))?;
if response.status() == StatusCode::NOT_FOUND { if response.status() == StatusCode::NOT_FOUND {
return Err(InstallExtensionError::NotFound { id }); return Err(format!("extension [{}] not found", id));
} }
let bytes = response let bytes = response
.bytes() .bytes()
.await .await
.context(DecodeResponseSnafu) .map_err(|e| format!("Failed to read response bytes: {}", e))?;
.context(DownloadFailureSnafu)?;
let cursor = std::io::Cursor::new(bytes); let cursor = std::io::Cursor::new(bytes);
let mut archive = zip::ZipArchive::new(cursor).context(ZipArchiveDecodingSnafu)?; let mut archive =
zip::ZipArchive::new(cursor).map_err(|e| format!("Failed to read zip archive: {}", e))?;
// The plugin.json sent from the server does not conform to our `struct Extension` definition: // The plugin.json sent from the server does not conform to our `struct Extension` definition:
// //
@@ -273,48 +255,23 @@ pub(crate) async fn install_extension_from_store(
// we need to correct it // we need to correct it
let mut plugin_json = archive let mut plugin_json = archive
.by_name(PLUGIN_JSON_FILE_NAME) .by_name(PLUGIN_JSON_FILE_NAME)
.context(ZipArchiveDecodingSnafu)?; .map_err(|e| e.to_string())?;
let mut plugin_json_content = String::new(); let mut plugin_json_content = String::new();
std::io::Read::read_to_string(&mut plugin_json, &mut plugin_json_content)
std::io::Read::read_to_string(&mut plugin_json, &mut plugin_json_content).context(IoSnafu)?; .map_err(|e| e.to_string())?;
let mut extension: Json = serde_json::from_str(&plugin_json_content) let mut extension: Json = serde_json::from_str(&plugin_json_content)
.context(DecodePluginJsonSnafu) .map_err(|e| format!("Failed to parse plugin.json: {}", e))?;
.context(InvalidExtensionSnafu)?;
let compatible_with_app = check_compatibility_via_mcv(&extension) let mut_ref_to_developer_object: &mut Json = extension
.context(ParseMinimumCocoVersionSnafu)
.context(InvalidExtensionSnafu)?;
if !compatible_with_app {
return Err(InstallExtensionError::IncompatibleCocoApp);
}
let extension_object = extension
.as_object_mut() .as_object_mut()
.ok_or_else(|| InvalidExtensionError::DecodePluginJson { .expect("plugin.json should be an object")
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") .get_mut("developer")
.ok_or_else(|| InvalidExtensionError::DecodePluginJson { .expect("plugin.json should contain field [developer]");
source: serde::de::Error::missing_field("developer"),
})
.context(InvalidExtensionSnafu)?;
let developer_id = mut_ref_to_developer_object let developer_id = mut_ref_to_developer_object
.get("id") .get("id")
.ok_or_else(|| InvalidExtensionError::DecodePluginJson { .expect("plugin.json should contain [developer.id]")
source: serde::de::Error::missing_field("id"),
})
.context(InvalidExtensionSnafu)?
.as_str() .as_str()
.ok_or_else(|| InvalidExtensionError::DecodePluginJson { .expect("plugin.json field [developer.id] should be a string");
source: serde::de::Error::custom("field 'id' should be of type 'string'"),
})
.context(InvalidExtensionSnafu)?;
*mut_ref_to_developer_object = Json::String(developer_id.into()); *mut_ref_to_developer_object = Json::String(developer_id.into());
// Set IDs for sub-extensions (commands, quicklinks, scripts) // Set IDs for sub-extensions (commands, quicklinks, scripts)
@@ -339,33 +296,27 @@ pub(crate) async fn install_extension_from_store(
set_ids_for_field(&mut extension, "scripts", &mut counter); set_ids_for_field(&mut extension, "scripts", &mut counter);
// Now the extension JSON is valid // Now the extension JSON is valid
let mut extension: Extension = serde_json::from_value(extension) let mut extension: Extension = serde_json::from_value(extension).unwrap_or_else(|e| {
.context(DecodePluginJsonSnafu) panic!(
.context(InvalidExtensionSnafu)?; "cannot parse plugin.json as struct Extension, error [{:?}]",
e
let developer_id = extension );
.developer });
.clone() let developer_id = extension.developer.clone().expect("developer has been set");
.expect("we checked this field exists");
drop(plugin_json); drop(plugin_json);
general_check(&extension) general_check(&extension)?;
.context(InvalidPluginJsonSnafu)
.context(InvalidExtensionSnafu)?;
let current_platform = Platform::current(); let current_platform = Platform::current();
if let Some(ref platforms) = extension.platforms { if let Some(ref platforms) = extension.platforms {
if !platforms.contains(&current_platform) { if !platforms.contains(&current_platform) {
return Err(InstallExtensionError::IncompatiblePlatform { return Err("this extension is not compatible with your OS".into());
current_platform,
compatible_platforms: platforms.clone(),
});
} }
} }
if is_extension_installed(&developer_id, &id).await { if is_extension_installed(&developer_id, &id).await {
return Err(InstallExtensionError::AlreadyInstalled); return Err("Extension already installed.".into());
} }
// Extension is compatible with current platform, but it could contain sub // Extension is compatible with current platform, but it could contain sub
@@ -390,11 +341,11 @@ pub(crate) async fn install_extension_from_store(
}; };
tokio::fs::create_dir_all(extension_directory.as_path()) tokio::fs::create_dir_all(extension_directory.as_path())
.await .await
.context(IoSnafu)?; .map_err(|e| e.to_string())?;
// Extract all files except plugin.json // Extract all files except plugin.json
for i in 0..archive.len() { for i in 0..archive.len() {
let mut zip_file = archive.by_index(i).context(ZipArchiveDecodingSnafu)?; let mut zip_file = archive.by_index(i).map_err(|e| e.to_string())?;
// `.name()` is safe to use in our cases, the cases listed in the below // `.name()` is safe to use in our cases, the cases listed in the below
// page won't happen to us. // page won't happen to us.
// //
@@ -422,39 +373,82 @@ pub(crate) async fn install_extension_from_store(
{ {
tokio::fs::create_dir_all(parent_dir) tokio::fs::create_dir_all(parent_dir)
.await .await
.context(IoSnafu)?; .map_err(|e| e.to_string())?;
} }
let mut dest_file = tokio::fs::File::create(&dest_file_path) let mut dest_file = tokio::fs::File::create(&dest_file_path)
.await .await
.context(IoSnafu)?; .map_err(|e| e.to_string())?;
let mut src_bytes = Vec::with_capacity( let mut src_bytes = Vec::with_capacity(
zip_file zip_file
.size() .size()
.try_into() .try_into()
.expect("we won't have a extension file that is bigger than 4GiB"), .expect("we won't have a extension file that is bigger than 4GiB"),
); );
zip_file.read_to_end(&mut src_bytes).context(IoSnafu)?; zip_file
.read_to_end(&mut src_bytes)
.map_err(|e| e.to_string())?;
tokio::io::copy(&mut src_bytes.as_slice(), &mut dest_file) tokio::io::copy(&mut src_bytes.as_slice(), &mut dest_file)
.await .await
.context(IoSnafu)?; .map_err(|e| e.to_string())?;
} }
// Create plugin.json from the extension variable // Create plugin.json from the extension variable
let plugin_json_path = extension_directory.join(PLUGIN_JSON_FILE_NAME); let plugin_json_path = extension_directory.join(PLUGIN_JSON_FILE_NAME);
let extension_json = serde_json::to_string_pretty(&extension).unwrap_or_else(|e| { let extension_json = serde_json::to_string_pretty(&extension).map_err(|e| e.to_string())?;
panic!(
"failed to serialize extension {:?}, error:\n{}",
extension,
report_error(&e, ReportErrorStyle::MultipleLines)
)
});
tokio::fs::write(&plugin_json_path, extension_json) tokio::fs::write(&plugin_json_path, extension_json)
.await .await
.context(IoSnafu)?; .map_err(|e| e.to_string())?;
/*
* Call convert_page() to update the page files. This has to be done after
* writing the extension files
*/
let absolute_page_paths: Vec<PathBuf> = {
fn canonicalize_page_path(page_path: &Path, extension_root: &Path) -> PathBuf {
if page_path.is_relative() {
// It is relative to the extension root directory
extension_root.join(page_path)
} else {
page_path.into()
}
}
if extension.r#type == ExtensionType::View {
let page = extension
.page
.as_ref()
.expect("View extension should set its page field");
let path = canonicalize_page_path(Path::new(page.as_str()), &extension_directory);
vec![path]
} else if extension.r#type.contains_sub_items()
&& let Some(ref views) = extension.views
{
let mut paths = Vec::with_capacity(views.len());
for view in views.iter() {
let page = view
.page
.as_ref()
.expect("View extension should set its page field");
let path = canonicalize_page_path(Path::new(page.as_str()), &extension_directory);
paths.push(path);
}
paths
} else {
// No pages in this extension
Vec::new()
}
};
for page_path in absolute_page_paths {
convert_page(&page_path).await?;
}
// Canonicalize relative icon and page paths // Canonicalize relative icon and page paths
canonicalize_relative_icon_path(&extension_directory, &mut extension).context(IoSnafu)?; canonicalize_relative_icon_path(&extension_directory, &mut extension)?;
canonicalize_relative_page_path(&extension_directory, &mut extension).context(IoSnafu)?; canonicalize_relative_page_path(&extension_directory, &mut extension)?;
third_party_ext_list_write_lock.push(extension); third_party_ext_list_write_lock.push(extension);

View File

@@ -9,30 +9,20 @@ use super::canonicalize_relative_icon_path;
use crate::common::document::DataSourceReference; use crate::common::document::DataSourceReference;
use crate::common::document::Document; use crate::common::document::Document;
use crate::common::document::open; use crate::common::document::open;
use crate::common::error::ReportErrorStyle;
use crate::common::error::SearchError; use crate::common::error::SearchError;
use crate::common::error::report_error;
use crate::common::search::QueryResponse; use crate::common::search::QueryResponse;
use crate::common::search::QuerySource; use crate::common::search::QuerySource;
use crate::common::search::SearchQuery; use crate::common::search::SearchQuery;
use crate::common::traits::SearchSource; use crate::common::traits::SearchSource;
use crate::extension::ExtensionBundleIdBorrowed; use crate::extension::ExtensionBundleIdBorrowed;
use crate::extension::ExtensionType;
use crate::extension::PLUGIN_JSON_FIELD_MINIMUM_COCO_VERSION;
use crate::extension::calculate_text_similarity; use crate::extension::calculate_text_similarity;
use crate::extension::canonicalize_relative_page_path; use crate::extension::canonicalize_relative_page_path;
use crate::extension::is_extension_compatible;
use crate::util::platform::Platform; use crate::util::platform::Platform;
use crate::util::version::COCO_VERSION;
use crate::util::version::parse_coco_semver;
use async_trait::async_trait; use async_trait::async_trait;
use borrowme::ToOwned; use borrowme::ToOwned;
use check::general_check; use check::general_check;
use function_name::named; use function_name::named;
use semver::Version as SemVer;
use serde_json::Value as Json;
use std::io::ErrorKind; use std::io::ErrorKind;
use std::ops::Deref;
use std::path::Path; use std::path::Path;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Arc; use std::sync::Arc;
@@ -132,156 +122,6 @@ pub(crate) async fn load_third_party_extensions_from_directory(
let plugin_json_file_content = tokio::fs::read_to_string(&plugin_json_file_path) let plugin_json_file_content = tokio::fs::read_to_string(&plugin_json_file_path)
.await .await
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
let plugin_json = match serde_json::from_str::<Json>(&plugin_json_file_content) {
Ok(json) => json,
Err(e) => {
log::warn!(
"invalid extension: [{}]: file [{}] is not a JSON, error: '{}'",
extension_dir_file_name,
plugin_json_file_path.display(),
e
);
continue 'extension;
}
};
let opt_mcv: Option<SemVer> = {
match plugin_json.get(PLUGIN_JSON_FIELD_MINIMUM_COCO_VERSION) {
None => None,
// NULL is considered None as well.
Some(Json::Null) => None,
Some(mcv_json) => {
let Some(mcv_str) = mcv_json.as_str() else {
log::warn!(
"invalid extension: [{}]: field [{}] is not a 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)
}
}
};
let is_compatible: bool = match opt_mcv {
Some(ref mcv) => COCO_VERSION.deref() >= mcv,
None => true,
};
if !is_compatible {
/*
* Extract only these field: [id, name, icon, type] from the JSON,
* then return a minimal Extension instance with these fields set:
*
* - `id` and `developer`: to make it identifiable
* - `name`, `icon` and `type`: to display it in the Extensions page
* - `minimum_coco_version`: so that we can check compatibility using it
*/
let Some(id) = plugin_json.get("id").and_then(|v| v.as_str()) else {
log::warn!(
"invalid extension: [{}]: field [id] is missing or not a string",
extension_dir_file_name,
);
continue 'extension;
};
let Some(name) = plugin_json.get("name").and_then(|v| v.as_str()) else {
log::warn!(
"invalid extension: [{}]: field [name] is missing or not a string",
extension_dir_file_name,
);
continue 'extension;
};
let Some(icon) = plugin_json.get("icon").and_then(|v| v.as_str()) else {
log::warn!(
"invalid extension: [{}]: field [icon] is missing or not a string",
extension_dir_file_name,
);
continue 'extension;
};
let Some(extension_type_str) = plugin_json.get("type").and_then(|v| v.as_str())
else {
log::warn!(
"invalid extension: [{}]: field [type] is missing or not a string",
extension_dir_file_name,
);
continue 'extension;
};
let extension_type: ExtensionType = match serde_plain::from_str(extension_type_str)
{
Ok(t) => t,
// Future Coco may have new Extension types that the we don't know
//
// This should be the only place where `ExtensionType::Unknown`
// could be constructed.
Err(_e) => ExtensionType::Unknown,
};
// We don't extract the developer ID from the plugin.json to rely
// less on it.
let developer = developer_dir
.file_name()
.into_string()
.expect("developer ID should be UTF-8 encoded");
let mut incompatible_extension = Extension {
id: id.to_string(),
name: name.to_string(),
icon: icon.to_string(),
r#type: extension_type,
developer: Some(developer),
description: String::new(),
enabled: false,
platforms: None,
action: None,
quicklink: None,
commands: None,
scripts: None,
quicklinks: None,
views: None,
alias: None,
hotkey: None,
settings: None,
page: None,
ui: None,
permission: None,
minimum_coco_version: opt_mcv,
screenshots: None,
url: None,
version: None,
};
// Turn icon path into an absolute path if it is a valid relative path
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);
continue 'extension;
}
/*
* This is a compatible extension.
*/
let mut extension = match serde_json::from_str::<Extension>(&plugin_json_file_content) { let mut extension = match serde_json::from_str::<Extension>(&plugin_json_file_content) {
Ok(extension) => extension, Ok(extension) => extension,
Err(e) => { Err(e) => {
@@ -343,10 +183,8 @@ pub(crate) async fn load_third_party_extensions_from_directory(
/* Check ends here */ /* Check ends here */
// Turn it into an absolute path if it is a valid relative path because frontend code needs this. // 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_icon_path(&extension_dir.path(), &mut extension)?;
.map_err(|e| report_error(&e, ReportErrorStyle::SingleLine))?; canonicalize_relative_page_path(&extension_dir.path(), &mut extension)?;
canonicalize_relative_page_path(&extension_dir.path(), &mut extension)
.map_err(|e| report_error(&e, ReportErrorStyle::SingleLine))?;
extensions.push(extension); extensions.push(extension);
} }
@@ -408,6 +246,7 @@ impl ThirdPartyExtensionsSearchSource {
if extension.supports_alias_hotkey() { if extension.supports_alias_hotkey() {
if let Some(ref hotkey) = extension.hotkey { if let Some(ref hotkey) = extension.hotkey {
let on_opened = extension.on_opened().unwrap_or_else(|| panic!( "extension has hotkey, but on_open() returns None, extension ID [{}], extension type [{:?}]", extension.id, extension.r#type)); let on_opened = extension.on_opened().unwrap_or_else(|| panic!( "extension has hotkey, but on_open() returns None, extension ID [{}], extension type [{:?}]", extension.id, extension.r#type));
let extension_id_clone = extension.id.clone(); let extension_id_clone = extension.id.clone();
tauri_app_handle tauri_app_handle
@@ -916,28 +755,13 @@ impl SearchSource for ThirdPartyExtensionsSearchSource {
let extensions_read_lock = let extensions_read_lock =
futures::executor::block_on(async { inner_clone.extensions.read().await }); futures::executor::block_on(async { inner_clone.extensions.read().await });
for extension in extensions_read_lock for extension in extensions_read_lock.iter().filter(|ext| ext.enabled) {
.iter()
// field minimum_coco_extension is only set for main extensions.
.filter(|ext| ext.enabled && is_extension_compatible(Extension::clone(ext)))
{
if extension.r#type.contains_sub_items() { if extension.r#type.contains_sub_items() {
let opt_main_extension_lowercase_name =
if extension.r#type == ExtensionType::Extension {
Some(extension.name.to_lowercase())
} else {
// None if it is of type `ExtensionType::Group`
None
};
if let Some(ref commands) = extension.commands { if let Some(ref commands) = extension.commands {
for command in commands.iter().filter(|cmd| cmd.enabled) { for command in commands.iter().filter(|cmd| cmd.enabled) {
if let Some(hit) = extension_to_hit( if let Some(hit) =
command, extension_to_hit(command, &query_lower, opt_data_source.as_deref())
&query_lower, {
opt_data_source.as_deref(),
opt_main_extension_lowercase_name.as_deref(),
) {
hits.push(hit); hits.push(hit);
} }
} }
@@ -945,12 +769,9 @@ impl SearchSource for ThirdPartyExtensionsSearchSource {
if let Some(ref scripts) = extension.scripts { if let Some(ref scripts) = extension.scripts {
for script in scripts.iter().filter(|script| script.enabled) { for script in scripts.iter().filter(|script| script.enabled) {
if let Some(hit) = extension_to_hit( if let Some(hit) =
script, extension_to_hit(script, &query_lower, opt_data_source.as_deref())
&query_lower, {
opt_data_source.as_deref(),
opt_main_extension_lowercase_name.as_deref(),
) {
hits.push(hit); hits.push(hit);
} }
} }
@@ -962,7 +783,6 @@ impl SearchSource for ThirdPartyExtensionsSearchSource {
quicklink, quicklink,
&query_lower, &query_lower,
opt_data_source.as_deref(), opt_data_source.as_deref(),
opt_main_extension_lowercase_name.as_deref(),
) { ) {
hits.push(hit); hits.push(hit);
} }
@@ -970,20 +790,17 @@ impl SearchSource for ThirdPartyExtensionsSearchSource {
} }
if let Some(ref views) = extension.views { if let Some(ref views) = extension.views {
for view in views.iter().filter(|view| view.enabled) { for view in views.iter().filter(|link| link.enabled) {
if let Some(hit) = extension_to_hit( if let Some(hit) =
view, extension_to_hit(view, &query_lower, opt_data_source.as_deref())
&query_lower, {
opt_data_source.as_deref(),
opt_main_extension_lowercase_name.as_deref(),
) {
hits.push(hit); hits.push(hit);
} }
} }
} }
} else { } else {
if let Some(hit) = if let Some(hit) =
extension_to_hit(extension, &query_lower, opt_data_source.as_deref(), None) extension_to_hit(extension, &query_lower, opt_data_source.as_deref())
{ {
hits.push(hit); hits.push(hit);
} }
@@ -1022,18 +839,10 @@ pub(crate) async fn uninstall_extension(
.await .await
} }
/// Argument `opt_main_extension_lowercase_name`: If `extension` is a sub-extension
/// of an `extension` type extension, then this argument contains the lowercase
/// name of that extension. Otherwise, None.
///
/// This argument is needed as an "extension" type extension should return all its
/// sub-extensions when the query string matches its name. To do this, we pass the
/// extension name, score it and take that into account.
pub(crate) fn extension_to_hit( pub(crate) fn extension_to_hit(
extension: &Extension, extension: &Extension,
query_lower: &str, query_lower: &str,
opt_data_source: Option<&str>, opt_data_source: Option<&str>,
opt_main_extension_lowercase_name: Option<&str>,
) -> Option<(Document, f64)> { ) -> Option<(Document, f64)> {
if !extension.searchable() { if !extension.searchable() {
return None; return None;
@@ -1056,26 +865,14 @@ pub(crate) fn extension_to_hit(
if let Some(title_score) = if let Some(title_score) =
calculate_text_similarity(&query_lower, &extension.name.to_lowercase()) calculate_text_similarity(&query_lower, &extension.name.to_lowercase())
{ {
total_score += title_score; total_score += title_score * 1.0; // Weight for title
} }
// Score based on alias match if available // Score based on alias match if available
// Alias is considered less important than title, so it gets a lower weight. // Alias is considered less important than title, so it gets a lower weight.
if let Some(alias) = &extension.alias { if let Some(alias) = &extension.alias {
if let Some(alias_score) = calculate_text_similarity(&query_lower, &alias.to_lowercase()) { if let Some(alias_score) = calculate_text_similarity(&query_lower, &alias.to_lowercase()) {
total_score += alias_score; total_score += alias_score * 0.7; // Weight for alias
}
}
// An "extension" type extension should return all its
// sub-extensions when the query string matches its ID.
// To do this, we score the extension ID and take that
// into account.
if let Some(main_extension_lowercase_id) = opt_main_extension_lowercase_name {
if let Some(main_extension_score) =
calculate_text_similarity(&query_lower, main_extension_lowercase_id)
{
total_score += main_extension_score;
} }
} }

View File

@@ -1,38 +0,0 @@
//! View extension-related stuff
use actix_files::Files;
use actix_web::{App, HttpServer, dev::ServerHandle};
use std::path::Path;
use tokio::sync::Mutex;
static FILE_SERVER_HANDLE: Mutex<Option<ServerHandle>> = Mutex::const_new(None);
/// Start a static HTTP file server serving the directory specified by `path`.
/// Return the URL of the server.
pub(crate) async fn serve_files_in(path: &Path) -> String {
const ADDR: &str = "127.0.0.1";
let mut guard = FILE_SERVER_HANDLE.lock().await;
if let Some(prev_server_handle) = guard.take() {
prev_server_handle.stop(true).await;
}
let path = path.to_path_buf();
let http_server =
HttpServer::new(move || App::new().service(Files::new("/", &path).show_files_listing()))
// Set port to 0 and let OS assign a port to us
.bind((ADDR, 0))
.unwrap();
let assigned_port = http_server.addrs()[0].port();
let server = http_server.disable_signals().workers(1).run();
let new_handle = server.handle();
tokio::spawn(server);
*guard = Some(new_handle);
format!("http://{}:{}", ADDR, assigned_port)
}

View File

@@ -3,33 +3,26 @@ mod autostart;
mod common; mod common;
mod extension; mod extension;
mod search; mod search;
mod selection_monitor;
mod server; mod server;
mod settings; mod settings;
mod setup; mod setup;
mod shortcut; mod shortcut;
// We need this in main.rs, so it has to be pub mod util;
pub mod util;
use crate::common::register::SearchSourceRegistry; use crate::common::register::SearchSourceRegistry;
use crate::common::{CHECK_WINDOW_LABEL, MAIN_WINDOW_LABEL, SETTINGS_WINDOW_LABEL}; use crate::common::{CHECK_WINDOW_LABEL, MAIN_WINDOW_LABEL, SETTINGS_WINDOW_LABEL};
use crate::server::servers::{ use crate::server::servers::{load_or_insert_default_server, load_servers_token};
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 crate::util::prevent_default;
use autostart::change_autostart; use autostart::change_autostart;
use lazy_static::lazy_static; use lazy_static::lazy_static;
use std::sync::Mutex; use std::sync::Mutex;
use std::sync::OnceLock; use std::sync::OnceLock;
use tauri::{ use tauri::plugin::TauriPlugin;
AppHandle, Emitter, LogicalPosition, Manager, PhysicalPosition, WebviewWindow, WindowEvent, use tauri::{AppHandle, Emitter, Manager, PhysicalPosition, WebviewWindow, WindowEvent};
};
use tauri_plugin_autostart::MacosLauncher; use tauri_plugin_autostart::MacosLauncher;
/// Tauri store name /// Tauri store name
pub(crate) const COCO_TAURI_STORE: &str = "coco_tauri_store"; pub(crate) const COCO_TAURI_STORE: &str = "coco_tauri_store";
pub(crate) const WINDOW_CENTER_BASELINE_HEIGHT: i32 = 590;
lazy_static! { lazy_static! {
static ref PREVIOUS_MONITOR_NAME: Mutex<Option<String>> = Mutex::new(None); static ref PREVIOUS_MONITOR_NAME: Mutex<Option<String>> = Mutex::new(None);
@@ -51,37 +44,26 @@ async fn change_window_height(handle: AppHandle, height: u32) {
let mut size = window.outer_size().unwrap(); let mut size = window.outer_size().unwrap();
size.height = height; size.height = height;
window.set_size(size).unwrap(); window.set_size(size).unwrap();
// Center the window horizontally and vertically based on the baseline height of 590
let monitor = window.primary_monitor().ok().flatten().or_else(|| {
window
.available_monitors()
.ok()
.and_then(|ms| ms.into_iter().next())
});
if let Some(monitor) = monitor {
let monitor_position = monitor.position();
let monitor_size = monitor.size();
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 =
monitor_position.y + (monitor_size.height as i32 - WINDOW_CENTER_BASELINE_HEIGHT) / 2;
let _ = window.set_position(PhysicalPosition::new(x, y));
}
} }
// Removed unused Payload to avoid unnecessary serde derive macro invocations #[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,
}
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
let ctx = tauri::generate_context!(); let ctx = tauri::generate_context!();
let mut app_builder = tauri::Builder::default().plugin(tauri_plugin_clipboard_manager::init()); let mut app_builder = tauri::Builder::default();
// Set up logger first // Set up logger first
app_builder = app_builder.plugin(set_up_tauri_logger()); app_builder = app_builder.plugin(set_up_tauri_logger());
@@ -107,12 +89,11 @@ pub fn run() {
.plugin(tauri_plugin_process::init()) .plugin(tauri_plugin_process::init())
.plugin( .plugin(
tauri_plugin_updater::Builder::new() tauri_plugin_updater::Builder::new()
.default_version_comparator(crate::util::version::custom_version_comparator) .default_version_comparator(crate::util::updater::custom_version_comparator)
.build(), .build(),
) )
.plugin(tauri_plugin_windows_version::init()) .plugin(tauri_plugin_windows_version::init())
.plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_zustand::init())
.plugin(prevent_default::init()); .plugin(prevent_default::init());
// Conditional compilation for macOS // Conditional compilation for macOS
@@ -185,13 +166,10 @@ pub fn run() {
extension::third_party::install::store::install_extension_from_store, extension::third_party::install::store::install_extension_from_store,
extension::third_party::install::local_extension::install_local_extension, extension::third_party::install::local_extension::install_local_extension,
extension::third_party::uninstall_extension, extension::third_party::uninstall_extension,
extension::is_extension_compatible,
extension::api::apis, extension::api::apis,
extension::api::fs::read_dir, extension::api::fs::read_dir,
settings::set_allow_self_signature, settings::set_allow_self_signature,
settings::get_allow_self_signature, settings::get_allow_self_signature,
settings::set_local_query_source_weight,
settings::get_local_query_source_weight,
assistant::ask_ai, assistant::ask_ai,
crate::common::document::open, crate::common::document::open,
extension::built_in::file_search::config::get_file_system_config, extension::built_in::file_search::config::get_file_system_config,
@@ -201,9 +179,6 @@ pub fn run() {
setup::backend_setup, setup::backend_setup,
util::app_lang::update_app_lang, util::app_lang::update_app_lang,
util::path::path_absolute, util::path::path_absolute,
util::logging::app_log_dir,
selection_monitor::set_selection_enabled,
selection_monitor::get_selection_enabled,
]) ])
.setup(|app| { .setup(|app| {
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
@@ -213,6 +188,7 @@ pub fn run() {
log::trace!("Dock icon should be hidden now"); log::trace!("Dock icon should be hidden now");
} }
/* ----------- This code must be executed on the main thread and must not be relocated. ----------- */ /* ----------- This code must be executed on the main thread and must not be relocated. ----------- */
let app_handle = app.app_handle(); let app_handle = app.app_handle();
let main_window = app_handle.get_webview_window(MAIN_WINDOW_LABEL).unwrap(); let main_window = app_handle.get_webview_window(MAIN_WINDOW_LABEL).unwrap();
@@ -281,12 +257,6 @@ pub async fn init(app_handle: &AppHandle) {
.await; .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; extension::built_in::pizza_engine_runtime::start_pizza_engine_runtime().await;
} }
@@ -295,20 +265,9 @@ async fn show_coco(app_handle: AppHandle) {
if let Some(window) = app_handle.get_webview_window(MAIN_WINDOW_LABEL) { if let Some(window) = app_handle.get_webview_window(MAIN_WINDOW_LABEL) {
move_window_to_active_monitor(&window); move_window_to_active_monitor(&window);
cfg_if::cfg_if! {
if #[cfg(target_os = "macos")] {
use tauri_nspanel::ManagerExt;
let app_handle_clone = app_handle.clone();
app_handle.run_on_main_thread(move || {
let panel = app_handle_clone.get_webview_panel(MAIN_WINDOW_LABEL).unwrap();
panel.show_and_make_key();
}).unwrap();
} else {
let _ = window.show(); let _ = window.show();
let _ = window.unminimize(); let _ = window.unminimize();
// The Window Management (WM) extension (macOS-only) controls the // The Window Management (WM) extension (macOS-only) controls the
// frontmost window. Setting focus on macOS makes Coco the frontmost // frontmost window. Setting focus on macOS makes Coco the frontmost
// window, which means the WM extension would control Coco instead of other // window, which means the WM extension would control Coco instead of other
@@ -317,51 +276,82 @@ async fn show_coco(app_handle: AppHandle) {
// On Linux/Windows, however, setting focus is a necessity to ensure that // On Linux/Windows, however, setting focus is a necessity to ensure that
// users open Coco's window, then they can start typing, without needing // users open Coco's window, then they can start typing, without needing
// to click on the window. // to click on the window.
#[cfg(not(target_os = "macos"))]
let _ = window.set_focus(); let _ = window.set_focus();
}
};
let _ = app_handle.emit("show-coco", ()); let _ = app_handle.emit("show-coco", ());
} }
} }
#[tauri::command] #[tauri::command]
async fn hide_coco(app_handle: AppHandle) { async fn hide_coco(app: AppHandle) {
cfg_if::cfg_if! { if let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) {
if #[cfg(target_os = "macos")] {
use tauri_nspanel::ManagerExt;
let app_handle_clone = app_handle.clone();
app_handle.run_on_main_thread(move || {
let panel = app_handle_clone.get_webview_panel(MAIN_WINDOW_LABEL).expect("cannot find the main window/panel");
panel.hide();
}).unwrap();
} else {
let window = app_handle.get_webview_window(MAIN_WINDOW_LABEL).expect("cannot find the main window");
if let Err(err) = window.hide() { if let Err(err) = window.hide() {
log::error!("Failed to hide the window: {}", err); log::error!("Failed to hide the window: {}", err);
} else { } else {
log::debug!("Window successfully hidden."); log::debug!("Window successfully hidden.");
} }
} else {
log::error!("Main window not found.");
} }
};
} }
fn move_window_to_active_monitor(window: &WebviewWindow) { fn move_window_to_active_monitor(window: &WebviewWindow) {
let scale_factor = window.scale_factor().unwrap(); //dbg!("Moving window to active monitor");
// Try to get the available monitors, handle failure gracefully
let available_monitors = match window.available_monitors() {
Ok(monitors) => monitors,
Err(e) => {
log::error!("Failed to get monitors: {}", e);
return;
}
};
let point = window.cursor_position().unwrap(); // Attempt to get the cursor position, handle failure gracefully
let cursor_position = match window.cursor_position() {
Ok(pos) => Some(pos),
Err(e) => {
log::error!("Failed to get cursor position: {}", e);
None
}
};
let LogicalPosition { x, y } = point.to_logical(scale_factor); // Find the monitor that contains the cursor or default to the primary monitor
let target_monitor = if let Some(cursor_position) = cursor_position {
// Convert cursor position to integers
let cursor_x = cursor_position.x.round() as i32;
let cursor_y = cursor_position.y.round() as i32;
// Find the monitor that contains the cursor
available_monitors.into_iter().find(|monitor| {
let monitor_position = monitor.position();
let monitor_size = monitor.size();
cursor_x >= monitor_position.x
&& cursor_x <= monitor_position.x + monitor_size.width as i32
&& cursor_y >= monitor_position.y
&& cursor_y <= monitor_position.y + monitor_size.height as i32
})
} else {
None
};
// Use the target monitor or default to the primary monitor
let monitor = match target_monitor.or_else(|| window.primary_monitor().ok().flatten()) {
Some(monitor) => monitor,
None => {
log::error!("No monitor found!");
return;
}
};
match window.monitor_from_point(x, y) {
Ok(Some(monitor)) => {
if let Some(name) = monitor.name() { if let Some(name) = monitor.name() {
let previous_monitor_name = PREVIOUS_MONITOR_NAME.lock().unwrap(); let previous_monitor_name = PREVIOUS_MONITOR_NAME.lock().unwrap();
if let Some(ref prev_name) = *previous_monitor_name { if let Some(ref prev_name) = *previous_monitor_name {
if name.to_string() == *prev_name { if name.to_string() == *prev_name {
log::debug!("Currently on the same monitor"); log::debug!("Currently on the same monitor");
return; return;
} }
} }
@@ -370,7 +360,7 @@ fn move_window_to_active_monitor(window: &WebviewWindow) {
let monitor_position = monitor.position(); let monitor_position = monitor.position();
let monitor_size = monitor.size(); let monitor_size = monitor.size();
// Current window size for horizontal centering // Get the current size of the window
let window_size = match window.inner_size() { let window_size = match window.inner_size() {
Ok(size) => size, Ok(size) => size,
Err(e) => { Err(e) => {
@@ -378,31 +368,26 @@ fn move_window_to_active_monitor(window: &WebviewWindow) {
return; return;
} }
}; };
let window_width = window_size.width as i32; let window_width = window_size.width as i32;
let window_height = window_size.height as i32;
// Horizontal center uses actual width, vertical center uses 590 baseline // Calculate the new position to center the window on the monitor
let window_x = monitor_position.x + (monitor_size.width as i32 - window_width) / 2; let window_x = monitor_position.x + (monitor_size.width as i32 - window_width) / 2;
let window_y = monitor_position.y let window_y = monitor_position.y + (monitor_size.height as i32 - window_height) / 2;
+ (monitor_size.height as i32 - WINDOW_CENTER_BASELINE_HEIGHT) / 2;
// Move the window to the new position
if let Err(e) = window.set_position(PhysicalPosition::new(window_x, window_y)) { if let Err(e) = window.set_position(PhysicalPosition::new(window_x, window_y)) {
log::error!("Failed to move window: {}", e); log::error!("Failed to move window: {}", e);
} }
if let Some(name) = monitor.name() { if let Some(name) = monitor.name() {
log::debug!("Window moved to monitor: {}", name); log::debug!("Window moved to monitor: {}", name);
let mut previous_monitor = PREVIOUS_MONITOR_NAME.lock().unwrap(); let mut previous_monitor = PREVIOUS_MONITOR_NAME.lock().unwrap();
*previous_monitor = Some(name.to_string()); *previous_monitor = Some(name.to_string());
} }
} }
Ok(None) => {
log::error!("No monitor found at the specified point");
}
Err(e) => {
log::error!("Failed to get monitor from point: {}", e);
}
}
}
#[tauri::command] #[tauri::command]
async fn get_app_search_source(app_handle: AppHandle) -> Result<(), String> { async fn get_app_search_source(app_handle: AppHandle) -> Result<(), String> {
@@ -445,3 +430,135 @@ async fn hide_check(app_handle: AppHandle) {
window.hide().unwrap(); window.hide().unwrap();
} }
/// Log format:
///
/// ```text
/// [time] [log level] [file module:line] message
/// ```
///
/// Example:
///
///
/// ```text
/// [05-11 17:00:00] [INF] [coco_lib:625] Coco-AI started
/// ```
fn set_up_tauri_logger() -> TauriPlugin<tauri::Wry> {
use log::Level;
use log::LevelFilter;
use tauri_plugin_log::Builder;
/// Coco-AI app's default log level.
const DEFAULT_LOG_LEVEL: LevelFilter = LevelFilter::Info;
const LOG_LEVEL_ENV_VAR: &str = "COCO_LOG";
fn format_log_level(level: Level) -> &'static str {
match level {
Level::Trace => "TRC",
Level::Debug => "DBG",
Level::Info => "INF",
Level::Warn => "WAR",
Level::Error => "ERR",
}
}
fn format_target_and_line(record: &log::Record) -> String {
let mut str = record.target().to_string();
if let Some(line) = record.line() {
str.push(':');
str.push_str(&line.to_string());
}
str
}
/// Allow us to configure dynamic log levels via environment variable `COCO_LOG`.
///
/// Generally, it mirros the behavior of `env_logger`. Syntax: `COCO_LOG=[target][=][level][,...]`
///
/// * If this environment variable is not set, use the default log level.
/// * If it is set, respect it:
///
/// * `COCO_LOG=coco_lib` turns on all logging for the `coco_lib` module, which is
/// equivalent to `COCO_LOG=coco_lib=trace`
/// * `COCO_LOG=trace` turns on all logging for the application, regardless of its name
/// * `COCO_LOG=TRACE` turns on all logging for the application, regardless of its name (same as previous)
/// * `COCO_LOG=reqwest=debug` turns on debug logging for `reqwest`
/// * `COCO_LOG=trace,tauri=off` turns on all the logging except for the logs come from `tauri`
/// * `COCO_LOG=off` turns off all logging for the application
/// * `COCO_LOG=` Since the value is empty, turns off all logging for the application as well
fn dynamic_log_level(mut builder: Builder) -> Builder {
let Some(log_levels) = std::env::var_os(LOG_LEVEL_ENV_VAR) else {
return builder.level(DEFAULT_LOG_LEVEL);
};
builder = builder.level(LevelFilter::Off);
let log_levels = log_levels.into_string().unwrap_or_else(|e| {
panic!(
"The value '{}' set in environment varaible '{}' is not UTF-8 encoded",
// Cannot use `.display()` here becuase that requires MSRV 1.87.0
e.to_string_lossy(),
LOG_LEVEL_ENV_VAR
)
});
// COCO_LOG=[target][=][level][,...]
let target_log_levels = log_levels.split(',');
for target_log_level in target_log_levels {
#[allow(clippy::collapsible_else_if)]
if let Some(char_index) = target_log_level.chars().position(|c| c == '=') {
let (target, equal_sign_and_level) = target_log_level.split_at(char_index);
// Remove the equal sign, we know it takes 1 byte
let level = &equal_sign_and_level[1..];
if let Ok(level) = level.parse::<LevelFilter>() {
// Here we have to call `.to_string()` because `Cow<'static, str>` requires `&'static str`
builder = builder.level_for(target.to_string(), level);
} else {
panic!(
"log level '{}' set in '{}={}' is invalid",
level, target, level
);
}
} else {
if let Ok(level) = target_log_level.parse::<LevelFilter>() {
// This is a level
builder = builder.level(level);
} else {
// This is a target, enable all the logging
//
// Here we have to call `.to_string()` because `Cow<'static, str>` requires `&'static str`
builder = builder.level_for(target_log_level.to_string(), LevelFilter::Trace);
}
}
}
builder
}
// When running the built binary, set `COCO_LOG` to `coco_lib=trace` to capture all logs
// that come from Coco in the log file, which helps with debugging.
if !tauri::is_dev() {
// We have absolutely no guarantee that we (We have control over the Rust
// code, but definitely no idea about the libc C code, all the shared objects
// that we will link) will not concurrently read/write `envp`, so just use unsafe.
unsafe {
std::env::set_var("COCO_LOG", "coco_lib=trace");
}
}
let mut builder = tauri_plugin_log::Builder::new();
builder = builder.format(|out, message, record| {
let now = chrono::Local::now().format("%m-%d %H:%M:%S");
let level = format_log_level(record.level());
let target_and_line = format_target_and_line(record);
out.finish(format_args!(
"[{}] [{}] [{}] {}",
now, level, target_and_line, message
));
});
builder = dynamic_log_level(builder);
builder.build()
}

View File

@@ -1,9 +1,42 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!! // Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use coco_lib::util::logging::app_log_dir;
use std::fs::OpenOptions; use std::fs::OpenOptions;
use std::io::Write; use std::io::Write;
use std::path::PathBuf;
/// Helper function to return the log directory.
///
/// This should return the same value as `tauri_app_handle.path().app_log_dir().unwrap()`.
fn app_log_dir() -> PathBuf {
// This function `app_log_dir()` is for the panic hook, which should be set
// before Tauri performs any initialization. At that point, we do not have
// access to the identifier provided by Tauri, so we need to define our own
// one here.
//
// NOTE: If you update identifier in the following files, update this one
// as well!
//
// src-tauri/tauri.linux.conf.json
// src-tauri/Entitlements.plist
// src-tauri/tauri.conf.json
// src-tauri/Info.plist
const IDENTIFIER: &str = "rs.coco.app";
#[cfg(target_os = "macos")]
let path = dirs::home_dir()
.expect("cannot find the home directory, Coco should never run in such a environment")
.join("Library/Logs")
.join(IDENTIFIER);
#[cfg(not(target_os = "macos"))]
let path = dirs::data_local_dir()
.expect("app local dir is None, we should not encounter this")
.join(IDENTIFIER)
.join("logs");
path
}
/// Set up panic hook to log panic information to a file /// Set up panic hook to log panic information to a file
fn setup_panic_hook() { fn setup_panic_hook() {
@@ -69,9 +102,6 @@ fn setup_panic_hook() {
eprintln!("Panic hook error: Failed to open panic log file: {}", e); 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,22 +1,22 @@
use crate::common::error::{ReportErrorStyle, SearchError, report_error}; use crate::common::error::SearchError;
use crate::common::register::SearchSourceRegistry; use crate::common::register::SearchSourceRegistry;
use crate::common::search::{ use crate::common::search::{
FailedRequest, MultiSourceQueryResponse, QueryHits, QuerySource, SearchQuery, FailedRequest, MultiSourceQueryResponse, QueryHits, QuerySource, SearchQuery,
}; };
use crate::common::traits::SearchSource; 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::logout_coco_server;
use crate::server::servers::mark_server_as_offline; use crate::server::servers::mark_server_as_offline;
use crate::settings::get_local_query_source_weight;
use function_name::named; use function_name::named;
use futures::StreamExt; use futures::StreamExt;
use futures::stream::FuturesUnordered; use futures::stream::FuturesUnordered;
use reqwest::StatusCode; use reqwest::StatusCode;
use std::cmp::Reverse;
use std::collections::HashMap; use std::collections::HashMap;
use std::collections::HashSet;
use std::sync::Arc; use std::sync::Arc;
use tauri::{AppHandle, Manager}; use tauri::{AppHandle, Manager};
use tokio::time::{Duration, timeout}; use tokio::time::{Duration, timeout};
#[named] #[named]
#[tauri::command] #[tauri::command]
pub async fn query_coco_fusion( pub async fn query_coco_fusion(
@@ -187,6 +187,7 @@ async fn query_coco_fusion_multi_query_sources(
let mut futures = FuturesUnordered::new(); let mut futures = FuturesUnordered::new();
let query_source_list_len = query_source_trait_object_list.len();
for query_source_trait_object in query_source_trait_object_list { for query_source_trait_object in query_source_trait_object_list {
let query_source = query_source_trait_object.get_type().clone(); let query_source = query_source_trait_object.get_type().clone();
let tauri_app_handle_clone = tauri_app_handle.clone(); let tauri_app_handle_clone = tauri_app_handle.clone();
@@ -207,8 +208,14 @@ async fn query_coco_fusion_multi_query_sources(
} }
let mut total_hits = 0; let mut total_hits = 0;
let mut need_rerank = true; //TODO set default to false when boost supported in Pizza
let mut failed_requests = Vec::new(); let mut failed_requests = Vec::new();
let mut all_hits_grouped_by_query_source: HashMap<QuerySource, Vec<QueryHits>> = HashMap::new(); let mut all_hits: Vec<(String, QueryHits, f64)> = Vec::new();
let mut hits_per_source: HashMap<String, Vec<(QueryHits, f64)>> = HashMap::new();
if query_source_list_len > 1 {
need_rerank = true; // If we have more than one source, we need to rerank the hits
}
while let Some((query_source, timeout_result)) = futures.next().await { while let Some((query_source, timeout_result)) = futures.next().await {
match timeout_result { match timeout_result {
@@ -222,6 +229,7 @@ async fn query_coco_fusion_multi_query_sources(
Ok(query_result) => match query_result { Ok(query_result) => match query_result {
Ok(response) => { Ok(response) => {
total_hits += response.total_hits; total_hits += response.total_hits;
let source_id = response.source.id.clone();
for (document, score) in response.hits { for (document, score) in response.hits {
log::debug!( log::debug!(
@@ -238,10 +246,12 @@ async fn query_coco_fusion_multi_query_sources(
document, document,
}; };
all_hits_grouped_by_query_source all_hits.push((source_id.clone(), query_hit.clone(), score));
.entry(query_source.clone())
hits_per_source
.entry(source_id.clone())
.or_insert_with(Vec::new) .or_insert_with(Vec::new)
.push(query_hit); .push((query_hit, score));
} }
} }
Err(search_error) => { Err(search_error) => {
@@ -257,129 +267,109 @@ async fn query_coco_fusion_multi_query_sources(
} }
} }
let n_sources = all_hits_grouped_by_query_source.len(); // Sort hits within each source by score (descending)
for hits in hits_per_source.values_mut() {
if n_sources == 0 { hits.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Greater));
return Ok(MultiSourceQueryResponse {
failed: Vec::new(),
hits: Vec::new(),
total_hits: 0,
});
} }
/* let total_sources = hits_per_source.len();
* Apply settings: local query source weight let max_hits_per_source = if total_sources > 0 {
*/ size as usize / total_sources
let local_query_source_weight: f64 = get_local_query_source_weight(tauri_app_handle);
// Scores remain unchanged if it is 1.0
if local_query_source_weight != 1.0 {
for (query_source, hits) in all_hits_grouped_by_query_source.iter_mut() {
if query_source.r#type == LOCAL_QUERY_SOURCE_TYPE {
hits.iter_mut()
.for_each(|hit| hit.score = hit.score * local_query_source_weight);
}
}
}
/*
* Sort hits within each source by score (descending) in case data sources
* do not sort them
*/
for hits in all_hits_grouped_by_query_source.values_mut() {
hits.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Greater)
});
}
/*
* Collect hits evenly across sources, to ensure:
*
* 1. All sources have hits returned
* 2. Query sources with many hits won't dominate
*/
let mut final_hits_grouped_by_source_id: HashMap<String, Vec<QueryHits>> = HashMap::new();
let mut pruned: HashMap<&str, &[QueryHits]> = HashMap::new();
// Include at least 2 hits from each query source
let max_hits_per_source = (size as usize / n_sources).max(2);
for (query_source, hits) in all_hits_grouped_by_query_source.iter() {
let hits_taken = if hits.len() > max_hits_per_source {
pruned.insert(&query_source.id, &hits[max_hits_per_source..]);
hits[0..max_hits_per_source].to_vec()
} else { } else {
hits.clone() size as usize
}; };
final_hits_grouped_by_source_id.insert(query_source.id.clone(), hits_taken);
}
let final_hits_len = final_hits_grouped_by_source_id
.iter()
.fold(0, |acc: usize, (_source_id, hits)| acc + hits.len());
let pruned_len = pruned
.iter()
.fold(0, |acc: usize, (_source_id, hits)| acc + hits.len());
/*
* If we still need more hits, take the highest-scoring from `pruned`
*
* `pruned` contains sorted arrays, we scan it in a way similar to
* how n-way-merge-sort extracts the element with the greatest value.
*/
if final_hits_len < size as usize {
let n_need = size as usize - final_hits_len;
let n_have = pruned_len;
let n_take = n_have.min(n_need);
for _ in 0..n_take {
let mut highest_score_hit: Option<(&str, &QueryHits)> = None;
for (source_id, sorted_hits) in pruned.iter_mut() {
if sorted_hits.is_empty() {
continue;
}
let hit = &sorted_hits[0];
let have_higher_score_hit = match highest_score_hit {
Some((_, current_highest_score_hit)) => {
hit.score > current_highest_score_hit.score
}
None => true,
};
if have_higher_score_hit {
highest_score_hit = Some((*source_id, hit));
// Advance sorted_hits by 1 element, if have
if sorted_hits.len() == 1 {
*sorted_hits = &[];
} else {
*sorted_hits = &sorted_hits[1..];
}
}
}
let (source_id, hit) = highest_score_hit.expect("`pruned` should contain at least `n_take` elements so `highest_score_hit` should be set");
final_hits_grouped_by_source_id
.get_mut(source_id)
.expect("all the source_ids stored in `pruned` come from `final_hits_grouped_by_source_id`, so it should exist")
.push(hit.clone());
}
}
/*
* Re-rank the final hits
*/
if n_sources > 1 {
boosted_levenshtein_rerank(&query_keyword, &mut final_hits_grouped_by_source_id);
}
let mut final_hits = Vec::new(); let mut final_hits = Vec::new();
for (_source_id, hits) in final_hits_grouped_by_source_id { let mut seen_docs = HashSet::new(); // To track documents we've already added
final_hits.extend(hits);
// Distribute hits fairly across sources
for (_source_id, hits) in &mut hits_per_source {
let take_count = hits.len().min(max_hits_per_source);
for (doc, score) in hits.drain(0..take_count) {
if !seen_docs.contains(&doc.document.id) {
seen_docs.insert(doc.document.id.clone());
log::debug!(
"collect doc: {}, {:?}, {}",
doc.document.id,
doc.document.title,
score
);
final_hits.push(doc);
}
}
}
log::debug!("final hits: {:?}", final_hits.len());
let mut unique_sources = HashSet::new();
for hit in &final_hits {
if let Some(source) = &hit.source {
if source.id != crate::extension::built_in::calculator::DATA_SOURCE_ID {
unique_sources.insert(&source.id);
}
}
}
log::debug!(
"Multiple sources found: {:?}, no rerank needed",
unique_sources
);
if unique_sources.len() < 1 {
need_rerank = false; // If we have hits from multiple sources, we don't need to rerank
}
if need_rerank && final_hits.len() > 1 {
// Precollect (index, title)
let titles_to_score: Vec<(usize, &str)> = final_hits
.iter()
.enumerate()
.filter_map(|(idx, hit)| {
let source = hit.source.as_ref()?;
let title = hit.document.title.as_deref()?;
if source.id != crate::extension::built_in::calculator::DATA_SOURCE_ID {
Some((idx, title))
} else {
None
}
})
.collect();
// Score them
let scored_hits = boosted_levenshtein_rerank(query_keyword.as_str(), titles_to_score);
// Sort descending by score
let mut scored_hits = scored_hits;
scored_hits.sort_by_key(|&(_, score)| Reverse((score * 1000.0) as u64));
// Apply new scores to final_hits
for (idx, score) in scored_hits.into_iter().take(size as usize) {
final_hits[idx].score = score;
}
} else if final_hits.len() < size as usize {
// If we still need more hits, take the highest-scoring remaining ones
let remaining_needed = size as usize - final_hits.len();
// Sort all hits by score descending, removing duplicates by document ID
all_hits.sort_by(|a, b| b.2.partial_cmp(&a.2).unwrap_or(std::cmp::Ordering::Equal));
let extra_hits = all_hits
.into_iter()
.filter(|(source_id, _, _)| hits_per_source.contains_key(source_id)) // Only take from known sources
.filter_map(|(_, doc, _)| {
if !seen_docs.contains(&doc.document.id) {
seen_docs.insert(doc.document.id.clone());
Some(doc)
} else {
None
}
})
.take(remaining_needed)
.collect::<Vec<_>>();
final_hits.extend(extra_hits);
} }
// **Sort final hits by score descending** // **Sort final hits by score descending**
@@ -389,9 +379,6 @@ async fn query_coco_fusion_multi_query_sources(
.unwrap_or(std::cmp::Ordering::Equal) .unwrap_or(std::cmp::Ordering::Equal)
}); });
// Truncate `final_hits` in case it contains more than `size` hits
final_hits.truncate(size as usize);
if final_hits.len() < 5 { if final_hits.len() < 5 {
//TODO: Add a recommendation system to suggest more sources //TODO: Add a recommendation system to suggest more sources
log::info!( log::info!(
@@ -408,85 +395,30 @@ async fn query_coco_fusion_multi_query_sources(
}) })
} }
use std::collections::HashSet; fn boosted_levenshtein_rerank(query: &str, titles: Vec<(usize, &str)>) -> Vec<(usize, f64)> {
use strsim::levenshtein; use strsim::levenshtein;
fn boosted_levenshtein_rerank(
query: &str,
all_hits_grouped_by_source_id: &mut HashMap<String, Vec<QueryHits>>,
) {
let query_lower = query.to_lowercase(); let query_lower = query.to_lowercase();
for (source_id, hits) in all_hits_grouped_by_source_id.iter_mut() { titles
// Skip special sources like calculator .into_iter()
if source_id == crate::extension::built_in::calculator::DATA_SOURCE_ID { .map(|(idx, title)| {
continue;
}
for hit in hits.iter_mut() {
let document_title = hit.document.title.as_deref().unwrap_or("");
let document_title_lowercase = document_title.to_lowercase();
let new_score = {
let mut score = 0.0; let mut score = 0.0;
// --- Exact or substring boost --- if title.contains(query) {
if document_title.contains(query) {
score += 0.4; score += 0.4;
} else if document_title_lowercase.contains(&query_lower) { } else if title.to_lowercase().contains(&query_lower) {
score += 0.2; score += 0.2;
} }
// --- Levenshtein distance (character similarity) --- let dist = levenshtein(&query_lower, &title.to_lowercase());
let dist = levenshtein(&query_lower, &document_title_lowercase); let max_len = query_lower.len().max(title.len());
let max_len = query_lower.len().max(document_title.len()); if max_len > 0 {
let levenshtein_score = if max_len > 0 { score += (1.0 - (dist as f64 / max_len as f64)) as f32;
(1.0 - (dist as f64 / max_len as f64)) as f32
} else {
0.0
};
// --- Jaccard similarity (token overlap) ---
let jaccard_score = jaccard_similarity(&query_lower, &document_title_lowercase);
// --- Combine scores (weights adjustable) ---
// Levenshtein emphasizes surface similarity
// Jaccard emphasizes term overlap (semantic hint)
let hybrid_score = 0.7 * levenshtein_score + 0.3 * jaccard_score;
// --- Apply hybrid score ---
score += hybrid_score;
// --- Limit score range ---
score.min(1.0) as f64
};
hit.score = new_score;
}
}
} }
/// Compute token-based Jaccard similarity (idx, score.min(1.0) as f64)
fn jaccard_similarity(a: &str, b: &str) -> f32 { })
let a_tokens: HashSet<_> = tokenize(a).into_iter().collect();
let b_tokens: HashSet<_> = tokenize(b).into_iter().collect();
if a_tokens.is_empty() || b_tokens.is_empty() {
return 0.0;
}
let intersection = a_tokens.intersection(&b_tokens).count() as f32;
let union = a_tokens.union(&b_tokens).count() as f32;
intersection / union
}
/// Basic tokenizer (case-insensitive, alphanumeric words only)
fn tokenize(text: &str) -> Vec<String> {
text.to_lowercase()
.split(|c: char| !c.is_alphanumeric())
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.collect() .collect()
} }
@@ -508,20 +440,15 @@ async fn query_coco_fusion_handle_failed_request(
let mut status_code_num: u16 = 0; let mut status_code_num: u16 = 0;
if let SearchError::HttpError { source } = &search_error { if let SearchError::HttpError {
let opt_status_code = match source { status_code: opt_status_code,
HttpRequestError::RequestFailed { msg: _,
status, } = search_error
error_response_body_str: _, {
coco_server_api_error_response_body: _,
} => Some(status),
_ => None,
};
if let Some(status_code) = opt_status_code { if let Some(status_code) = opt_status_code {
status_code_num = *status_code; status_code_num = status_code.as_u16();
if *status_code != StatusCode::OK.as_u16() { if status_code != StatusCode::OK {
if *status_code == StatusCode::UNAUTHORIZED { if status_code == StatusCode::UNAUTHORIZED {
// This Coco server is unavailable. In addition to marking it as // This Coco server is unavailable. In addition to marking it as
// unavailable, we need to log out because the status code is 401. // 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| { logout_coco_server(tauri_app_handle.clone(), query_source.id.to_string()).await.unwrap_or_else(|e| {
@@ -541,7 +468,7 @@ async fn query_coco_fusion_handle_failed_request(
failed_requests.push(FailedRequest { failed_requests.push(FailedRequest {
source: query_source, source: query_source,
status: status_code_num, status: status_code_num,
error: Some(report_error(&search_error, ReportErrorStyle::SingleLine)), error: Some(search_error.to_string()),
reason: None, reason: None,
}); });
} }

View File

@@ -1,671 +0,0 @@
/// 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,14 +1,9 @@
use super::servers::{get_server_by_id, get_server_token}; 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::common::http::get_response_body_text;
use crate::server::http_client::{HttpClient, HttpRequestError, SendSnafu}; use crate::server::http_client::HttpClient;
use reqwest::multipart::{Form, Part}; use reqwest::multipart::{Form, Part};
use serde::Deserialize; use serde::{Deserialize, Serialize};
use serde::Serialize;
use serde_json::Value; use serde_json::Value;
use snafu::prelude::*;
use std::ffi::OsString;
use std::io;
use std::{collections::HashMap, path::PathBuf}; use std::{collections::HashMap, path::PathBuf};
use tauri::command; use tauri::command;
use tokio::fs::File; use tokio::fs::File;
@@ -26,66 +21,23 @@ pub struct DeleteAttachmentResponse {
pub result: String, 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] #[command]
pub async fn upload_attachment( pub async fn upload_attachment(
server_id: String, server_id: String,
file_paths: Vec<PathBuf>, file_paths: Vec<PathBuf>,
) -> Result<UploadAttachmentResponse, AttachmentError> { ) -> Result<UploadAttachmentResponse, String> {
let mut form = Form::new(); let mut form = Form::new();
for file_path in file_paths { for file_path in file_paths {
let file = match File::open(&file_path).await { let file = File::open(&file_path)
Ok(file) => file, .await
Err(io_err) => { .map_err(|err| err.to_string())?;
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 stream = FramedRead::new(file, BytesCodec::new());
let file_name_os_str = let file_name = file_path
file_path
.file_name() .file_name()
.ok_or_else(|| AttachmentError::NoFilename { .and_then(|n| n.to_str())
file: file_path.clone(), .ok_or("Invalid filename")?;
})?;
let file_name =
file_name_os_str
.to_str()
.ok_or_else(|| AttachmentError::NonUtf8Filename {
filename: file_name_os_str.to_os_string(),
})?;
let part = let part =
Part::stream(reqwest::Body::wrap_stream(stream)).file_name(file_name.to_string()); Part::stream(reqwest::Body::wrap_stream(stream)).file_name(file_name.to_string());
@@ -93,12 +45,9 @@ pub async fn upload_attachment(
form = form.part("files", part); form = form.part("files", part);
} }
let server = let server = get_server_by_id(&server_id)
get_server_by_id(&server_id)
.await .await
.ok_or_else(|| AttachmentError::ServerNotFound { .ok_or("Server not found")?;
id: server_id.clone(),
})?;
let url = HttpClient::join_url(&server.endpoint, &format!("attachment/_upload")); let url = HttpClient::join_url(&server.endpoint, &format!("attachment/_upload"));
let token = get_server_token(&server_id).await; let token = get_server_token(&server_id).await;
@@ -111,24 +60,22 @@ pub async fn upload_attachment(
let response = client let response = client
.post(url) .post(url)
.multipart(form) .multipart(form)
.headers((&headers).try_into().expect("conversion should not fail")) .headers((&headers).try_into().map_err(|err| format!("{}", err))?)
.send() .send()
.await .await
.context(SendSnafu) .map_err(|err| err.to_string())?;
.context(HttpRequestSnafu)?;
let body = get_response_body_text(response) let body = get_response_body_text(response).await?;
.await
.context(HttpRequestSnafu)?;
serde_json::from_str::<UploadAttachmentResponse>(&body).context(JsonDecodingSnafu) serde_json::from_str::<UploadAttachmentResponse>(&body)
.map_err(|e| format!("Failed to parse upload response: {}", e))
} }
#[command] #[command]
pub async fn get_attachment_by_ids( pub async fn get_attachment_by_ids(
server_id: String, server_id: String,
attachments: Vec<String>, attachments: Vec<String>,
) -> Result<Value, AttachmentError> { ) -> Result<Value, String> {
println!("get_attachment_by_ids server_id: {}", server_id); println!("get_attachment_by_ids server_id: {}", server_id);
println!("get_attachment_by_ids attachments: {:?}", attachments); println!("get_attachment_by_ids attachments: {:?}", attachments);
@@ -139,27 +86,28 @@ pub async fn get_attachment_by_ids(
let response = HttpClient::post(&server_id, "/attachment/_search", None, Some(body)) let response = HttpClient::post(&server_id, "/attachment/_search", None, Some(body))
.await .await
.context(HttpRequestSnafu)?; .map_err(|e| format!("Request error: {}", e))?;
let body = get_response_body_text(response) let body = get_response_body_text(response).await?;
.await
.context(HttpRequestSnafu)?;
serde_json::from_str::<Value>(&body).context(JsonDecodingSnafu) serde_json::from_str::<Value>(&body)
.map_err(|e| format!("Failed to parse attachment response: {}", e))
} }
#[command] #[command]
pub async fn delete_attachment(server_id: String, id: String) -> Result<bool, AttachmentError> { pub async fn delete_attachment(server_id: String, id: String) -> Result<bool, String> {
let response = HttpClient::delete(&server_id, &format!("/attachment/{}", id), None, None) let response = HttpClient::delete(&server_id, &format!("/attachment/{}", id), None, None)
.await .await
.context(HttpRequestSnafu)?; .map_err(|e| format!("Request error: {}", e))?;
let body = get_response_body_text(response) let body = get_response_body_text(response).await?;
.await
.context(HttpRequestSnafu)?;
let parsed: DeleteAttachmentResponse = let parsed: DeleteAttachmentResponse = serde_json::from_str(&body)
serde_json::from_str(&body).context(JsonDecodingSnafu)?; .map_err(|e| format!("Failed to parse delete response: {}", e))?;
Ok(parsed.result.eq("deleted")) parsed
.result
.eq("deleted")
.then_some(true)
.ok_or_else(|| "Delete operation was not successful".to_string())
} }

View File

@@ -1,13 +1,9 @@
use crate::common::error::ApiError;
use crate::common::error::serialize_error;
use crate::server::servers::{get_server_by_id, get_server_token}; use crate::server::servers::{get_server_by_id, get_server_token};
use crate::util::app_lang::get_app_lang; use crate::util::app_lang::get_app_lang;
use crate::util::platform::Platform; use crate::util::platform::Platform;
use http::{HeaderName, HeaderValue, StatusCode}; use http::{HeaderName, HeaderValue, StatusCode};
use once_cell::sync::Lazy; use once_cell::sync::Lazy;
use reqwest::{Client, Method, RequestBuilder}; use reqwest::{Client, Method, RequestBuilder};
use serde::Serialize;
use snafu::prelude::*;
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::LazyLock; use std::sync::LazyLock;
use std::time::Duration; use std::time::Duration;
@@ -33,52 +29,6 @@ pub static HTTP_CLIENT: Lazy<Mutex<Client>> = Lazy::new(|| {
Mutex::new(new_reqwest_http_client(allow_self_signature)) 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. /// These header values won't change during a process's lifetime.
static STATIC_HEADERS: LazyLock<HashMap<String, String>> = LazyLock::new(|| { static STATIC_HEADERS: LazyLock<HashMap<String, String>> = LazyLock::new(|| {
HashMap::from([ HashMap::from([
@@ -115,7 +65,7 @@ impl HttpClient {
query_params: Option<Vec<String>>, query_params: Option<Vec<String>>,
headers: Option<HashMap<String, String>>, headers: Option<HashMap<String, String>>,
body: Option<reqwest::Body>, body: Option<reqwest::Body>,
) -> Result<reqwest::Response, HttpRequestError> { ) -> Result<reqwest::Response, String> {
log::debug!( log::debug!(
"Sending Request: {}, query_params: {:?}, header: {:?}, body: {:?}", "Sending Request: {}, query_params: {:?}, header: {:?}, body: {:?}",
&url, &url,
@@ -127,16 +77,10 @@ impl HttpClient {
let request_builder = let request_builder =
Self::get_request_builder(method, url, headers, query_params, body).await; Self::get_request_builder(method, url, headers, query_params, body).await;
let response = match request_builder.send().await { let response = request_builder.send().await.map_err(|e| {
Ok(response) => response, //dbg!("Failed to send request: {}", &e);
Err(e) => { format!("Failed to send request: {}", e)
if e.is_timeout() { })?;
return Err(HttpRequestError::ConnectionTimeout);
} else {
return Err(HttpRequestError::SendError { source: e });
}
}
};
log::debug!( log::debug!(
"Request: {}, Response status: {:?}, header: {:?}", "Request: {}, Response status: {:?}, header: {:?}",
@@ -229,7 +173,7 @@ impl HttpClient {
custom_headers: Option<HashMap<String, String>>, custom_headers: Option<HashMap<String, String>>,
query_params: Option<Vec<String>>, query_params: Option<Vec<String>>,
body: Option<reqwest::Body>, body: Option<reqwest::Body>,
) -> Result<reqwest::Response, HttpRequestError> { ) -> Result<reqwest::Response, String> {
// Fetch the server using the server_id // Fetch the server using the server_id
let server = get_server_by_id(server_id).await; let server = get_server_by_id(server_id).await;
if let Some(s) = server { if let Some(s) = server {
@@ -261,9 +205,7 @@ impl HttpClient {
Self::send_raw_request(method, &url, query_params, Some(headers), body).await Self::send_raw_request(method, &url, query_params, Some(headers), body).await
} else { } else {
Err(HttpRequestError::ServerNotFound { Err(format!("Server [{}] not found", server_id))
id: server_id.to_string(),
})
} }
} }
@@ -272,7 +214,7 @@ impl HttpClient {
server_id: &str, server_id: &str,
path: &str, path: &str,
query_params: Option<Vec<String>>, query_params: Option<Vec<String>>,
) -> Result<reqwest::Response, HttpRequestError> { ) -> Result<reqwest::Response, String> {
HttpClient::send_request(server_id, Method::GET, path, None, query_params, None).await HttpClient::send_request(server_id, Method::GET, path, None, query_params, None).await
} }
@@ -282,7 +224,7 @@ impl HttpClient {
path: &str, path: &str,
query_params: Option<Vec<String>>, query_params: Option<Vec<String>>,
body: Option<reqwest::Body>, body: Option<reqwest::Body>,
) -> Result<reqwest::Response, HttpRequestError> { ) -> Result<reqwest::Response, String> {
HttpClient::send_request(server_id, Method::POST, path, None, query_params, body).await HttpClient::send_request(server_id, Method::POST, path, None, query_params, body).await
} }
@@ -292,7 +234,7 @@ impl HttpClient {
custom_headers: Option<HashMap<String, String>>, custom_headers: Option<HashMap<String, String>>,
query_params: Option<Vec<String>>, query_params: Option<Vec<String>>,
body: Option<reqwest::Body>, body: Option<reqwest::Body>,
) -> Result<reqwest::Response, HttpRequestError> { ) -> Result<reqwest::Response, String> {
HttpClient::send_request( HttpClient::send_request(
server_id, server_id,
Method::POST, Method::POST,
@@ -312,7 +254,7 @@ impl HttpClient {
custom_headers: Option<HashMap<String, String>>, custom_headers: Option<HashMap<String, String>>,
query_params: Option<Vec<String>>, query_params: Option<Vec<String>>,
body: Option<reqwest::Body>, body: Option<reqwest::Body>,
) -> Result<reqwest::Response, HttpRequestError> { ) -> Result<reqwest::Response, String> {
HttpClient::send_request( HttpClient::send_request(
server_id, server_id,
Method::PUT, Method::PUT,
@@ -331,7 +273,7 @@ impl HttpClient {
path: &str, path: &str,
custom_headers: Option<HashMap<String, String>>, custom_headers: Option<HashMap<String, String>>,
query_params: Option<Vec<String>>, query_params: Option<Vec<String>>,
) -> Result<reqwest::Response, HttpRequestError> { ) -> Result<reqwest::Response, String> {
HttpClient::send_request( HttpClient::send_request(
server_id, server_id,
Method::DELETE, Method::DELETE,

View File

@@ -1,14 +1,13 @@
use crate::common::document::{Document, OnOpened}; use crate::common::document::{Document, OnOpened};
use crate::common::error::{HttpSnafu, ResponseDecodeSnafu, SearchError}; use crate::common::error::SearchError;
use crate::common::http::get_response_body_text; use crate::common::http::get_response_body_text;
use crate::common::search::{QueryHits, QueryResponse, QuerySource, SearchQuery, SearchResponse}; use crate::common::search::{QueryHits, QueryResponse, QuerySource, SearchQuery, SearchResponse};
use crate::common::server::Server; use crate::common::server::Server;
use crate::common::traits::SearchSource; use crate::common::traits::SearchSource;
use crate::server::http_client::{HttpClient, HttpRequestError}; use crate::server::http_client::HttpClient;
use async_trait::async_trait; use async_trait::async_trait;
use ordered_float::OrderedFloat; use ordered_float::OrderedFloat;
use reqwest::StatusCode; use reqwest::StatusCode;
use snafu::ResultExt;
use std::collections::HashMap; use std::collections::HashMap;
use tauri::AppHandle; use tauri::AppHandle;
@@ -113,29 +112,31 @@ impl SearchSource for CocoSearchSource {
let response = HttpClient::get(&self.server.id, &url, Some(query_params)) let response = HttpClient::get(&self.server.id, &url, Some(query_params))
.await .await
.context(HttpSnafu)?; .map_err(|e| SearchError::HttpError {
status_code: None,
msg: format!("{}", e),
})?;
let status_code = response.status(); let status_code = response.status();
if ![StatusCode::OK, StatusCode::CREATED].contains(&status_code) { if ![StatusCode::OK, StatusCode::CREATED].contains(&status_code) {
let http_err = HttpRequestError::RequestFailed { return Err(SearchError::HttpError {
status: status_code.as_u16(), status_code: Some(status_code),
error_response_body_str: None, msg: format!("Request failed with status code [{}]", status_code),
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 // Use the helper function to parse the response body
let response_body = get_response_body_text(response).await.context(HttpSnafu)?; let response_body = get_response_body_text(response)
.await
.map_err(|e| SearchError::ParseError(e))?;
// Check if the response body is empty // Check if the response body is empty
if !response_body.is_empty() { if !response_body.is_empty() {
// log::info!("Search response body: {}", &response_body); // log::info!("Search response body: {}", &response_body);
// Parse the search response from the body text // Parse the search response from the body text
let parsed: SearchResponse<Document> = let parsed: SearchResponse<Document> = serde_json::from_str(&response_body)
serde_json::from_str(&response_body).context(ResponseDecodeSnafu)?; .map_err(|e| SearchError::ParseError(format!("{}", e)))?;
// Process the parsed response // Process the parsed response
total_hits = parsed.hits.total.value as usize; total_hits = parsed.hits.total.value as usize;

View File

@@ -1,5 +1,4 @@
use crate::COCO_TAURI_STORE; use crate::COCO_TAURI_STORE;
use crate::common::error::{ReportErrorStyle, report_error};
use crate::common::http::get_response_body_text; use crate::common::http::get_response_body_text;
use crate::common::register::SearchSourceRegistry; use crate::common::register::SearchSourceRegistry;
use crate::common::server::{AuthProvider, Provider, Server, ServerAccessToken, Sso, Version}; use crate::common::server::{AuthProvider, Provider, Server, ServerAccessToken, Sso, Version};
@@ -14,11 +13,8 @@ use serde_json::Value as JsonValue;
use serde_json::from_value; use serde_json::from_value;
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::LazyLock; use std::sync::LazyLock;
use std::thread;
use std::time::Duration;
use tauri::{AppHandle, Manager}; use tauri::{AppHandle, Manager};
use tauri_plugin_store::StoreExt; use tauri_plugin_store::StoreExt;
use tokio::runtime;
use tokio::sync::RwLock; use tokio::sync::RwLock;
/// Coco sever list /// Coco sever list
@@ -316,109 +312,6 @@ 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] #[tauri::command]
pub async fn refresh_coco_server_info(app_handle: AppHandle, id: String) -> Result<Server, String> { pub async fn refresh_coco_server_info(app_handle: AppHandle, id: String) -> Result<Server, String> {
// Retrieve the server from the cache // Retrieve the server from the cache
@@ -442,7 +335,7 @@ pub async fn refresh_coco_server_info(app_handle: AppHandle, id: String) -> Resu
Ok(response) => response, Ok(response) => response,
Err(e) => { Err(e) => {
mark_server_as_offline(app_handle, &id).await; mark_server_as_offline(app_handle, &id).await;
return Err(report_error(&e, ReportErrorStyle::SingleLine)); return Err(e);
} }
}; };
@@ -452,9 +345,7 @@ pub async fn refresh_coco_server_info(app_handle: AppHandle, id: String) -> Resu
} }
// Get body text via helper // Get body text via helper
let body = get_response_body_text(response) let body = get_response_body_text(response).await?;
.await
.map_err(|e| report_error(&e, ReportErrorStyle::SingleLine))?;
// Deserialize server // Deserialize server
let mut updated_server: Server = serde_json::from_str(&body) let mut updated_server: Server = serde_json::from_str(&body)
@@ -523,9 +414,7 @@ pub async fn add_coco_server(app_handle: AppHandle, endpoint: String) -> Result<
return Err("This Coco server is possibly down".into()); return Err("This Coco server is possibly down".into());
} }
let body = get_response_body_text(response) let body = get_response_body_text(response).await?;
.await
.map_err(|e| report_error(&e, ReportErrorStyle::SingleLine))?;
let mut server: Server = serde_json::from_str(&body) let mut server: Server = serde_json::from_str(&body)
.map_err(|e| format!("Failed to deserialize the response: {}", e))?; .map_err(|e| format!("Failed to deserialize the response: {}", e))?;

View File

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

View File

@@ -4,7 +4,6 @@ use tauri::AppHandle;
use tauri_plugin_store::StoreExt; use tauri_plugin_store::StoreExt;
const SETTINGS_ALLOW_SELF_SIGNATURE: &str = "settings_allow_self_signature"; const SETTINGS_ALLOW_SELF_SIGNATURE: &str = "settings_allow_self_signature";
const LOCAL_QUERY_SOURCE_WEIGHT: &str = "local_query_source_weight";
#[tauri::command] #[tauri::command]
pub async fn set_allow_self_signature(tauri_app_handle: AppHandle, value: bool) { pub async fn set_allow_self_signature(tauri_app_handle: AppHandle, value: bool) {
@@ -71,45 +70,3 @@ pub fn _get_allow_self_signature(tauri_app_handle: AppHandle) -> bool {
pub async fn get_allow_self_signature(tauri_app_handle: AppHandle) -> bool { pub async fn get_allow_self_signature(tauri_app_handle: AppHandle) -> bool {
_get_allow_self_signature(tauri_app_handle) _get_allow_self_signature(tauri_app_handle)
} }
#[tauri::command]
pub async fn set_local_query_source_weight(tauri_app_handle: AppHandle, value: f64) {
let store = tauri_app_handle
.store(COCO_TAURI_STORE)
.unwrap_or_else(|e| {
panic!(
"store [{}] not found/loaded, error [{}]",
COCO_TAURI_STORE, e
)
});
store.set(LOCAL_QUERY_SOURCE_WEIGHT, value);
}
#[tauri::command]
pub fn get_local_query_source_weight(tauri_app_handle: AppHandle) -> f64 {
// default to 1.0
const DEFAULT: f64 = 1.0;
let store = tauri_app_handle
.store(COCO_TAURI_STORE)
.unwrap_or_else(|e| {
panic!(
"store [{}] not found/loaded, error [{}]",
COCO_TAURI_STORE, e
)
});
if !store.has(LOCAL_QUERY_SOURCE_WEIGHT) {
store.set(LOCAL_QUERY_SOURCE_WEIGHT, DEFAULT);
}
match store
.get(LOCAL_QUERY_SOURCE_WEIGHT)
.expect("should be Some")
{
Json::Number(n) => n
.as_f64()
.unwrap_or_else(|| panic!("setting [{}] should be a f64", LOCAL_QUERY_SOURCE_WEIGHT)),
_ => unreachable!("{} should be stored as a number", LOCAL_QUERY_SOURCE_WEIGHT),
}
}

View File

@@ -1,26 +1,14 @@
//! credits to: https://github.com/ayangweb/ayangweb-EcoPaste/blob/169323dbe6365ffe4abb64d867439ed2ea84c6d1/src-tauri/src/core/setup/mac.rs //! credits to: https://github.com/ayangweb/ayangweb-EcoPaste/blob/169323dbe6365ffe4abb64d867439ed2ea84c6d1/src-tauri/src/core/setup/mac.rs
use crate::common::MAIN_WINDOW_LABEL; use crate::common::MAIN_WINDOW_LABEL;
use tauri::{AppHandle, Emitter, EventTarget, Manager, WebviewWindow}; use objc2_app_kit::NSNonactivatingPanelMask;
use tauri_nspanel::{CollectionBehavior, PanelLevel, StyleMask, WebviewWindowExt, tauri_panel}; use tauri::{AppHandle, Emitter, EventTarget, WebviewWindow};
use tauri_nspanel::{WebviewWindowExt, cocoa::appkit::NSWindowCollectionBehavior, panel_delegate};
const WINDOW_FOCUS_EVENT: &str = "tauri://focus"; const WINDOW_FOCUS_EVENT: &str = "tauri://focus";
const WINDOW_BLUR_EVENT: &str = "tauri://blur"; const WINDOW_BLUR_EVENT: &str = "tauri://blur";
const WINDOW_MOVED_EVENT: &str = "tauri://move";
tauri_panel! { const WINDOW_RESIZED_EVENT: &str = "tauri://resize";
panel!(NsPanel {
config: {
is_floating_panel: true,
can_become_key_window: true,
can_become_main_window: false
}
})
panel_event!(NsPanelEventHandler {
window_did_become_key(notification: &NSNotification) -> (),
window_did_resign_key(notification: &NSNotification) -> (),
})
}
pub fn platform( pub fn platform(
_tauri_app_handle: &AppHandle, _tauri_app_handle: &AppHandle,
@@ -29,39 +17,68 @@ pub fn platform(
_check_window: WebviewWindow, _check_window: WebviewWindow,
) { ) {
// Convert ns_window to ns_panel // Convert ns_window to ns_panel
let panel = main_window.to_panel::<NsPanel>().unwrap(); let panel = main_window.to_panel().unwrap();
// set level
panel.set_level(PanelLevel::Utility.value());
// Do not steal focus from other windows // Do not steal focus from other windows
panel.set_style_mask(StyleMask::empty().nonactivating_panel().into()); //
// Cast is safe
panel.set_style_mask(NSNonactivatingPanelMask.0 as i32);
// Set its level to NSFloatingWindowLevel to ensure it appears in front of
// all normal-level windows
//
// NOTE: some Chinese input methods use a level between NSDockWindowLevel (20)
// and NSMainMenuWindowLevel (24), setting our level above NSDockWindowLevel
// would block their window
panel.set_floating_panel(true);
// Open the window in the active workspace and full screen // Open the window in the active workspace and full screen
panel.set_collection_behavior( panel.set_collection_behaviour(
CollectionBehavior::new() NSWindowCollectionBehavior::NSWindowCollectionBehaviorMoveToActiveSpace
.stationary() | NSWindowCollectionBehavior::NSWindowCollectionBehaviorStationary
.move_to_active_space() | NSWindowCollectionBehavior::NSWindowCollectionBehaviorFullScreenAuxiliary,
.full_screen_auxiliary()
.into(),
); );
let handler = NsPanelEventHandler::new(); // Define the panel's delegate to listen to panel window events
let delegate = panel_delegate!(EcoPanelDelegate {
let window = main_window.clone(); window_did_become_key,
handler.window_did_become_key(move |_| { window_did_resign_key,
let target = EventTarget::labeled(MAIN_WINDOW_LABEL); window_did_resize,
window_did_move
let _ = window.emit_to(target, WINDOW_FOCUS_EVENT, true);
}); });
let window = main_window.clone(); // Set event listeners for the delegate
handler.window_did_resign_key(move |_| { delegate.set_listener(Box::new(move |delegate_name: String| {
let target = EventTarget::labeled(MAIN_WINDOW_LABEL); let target = EventTarget::labeled(MAIN_WINDOW_LABEL);
let _ = window.emit_to(target, WINDOW_BLUR_EVENT, true); let window_move_event = || {
}); if let Ok(position) = main_window.outer_position() {
let _ = main_window.emit_to(target.clone(), WINDOW_MOVED_EVENT, position);
}
};
match delegate_name.as_str() {
// Called when the window gets keyboard focus
"window_did_become_key" => {
let _ = main_window.emit_to(target, WINDOW_FOCUS_EVENT, true);
}
// Called when the window loses keyboard focus
"window_did_resign_key" => {
let _ = main_window.emit_to(target, WINDOW_BLUR_EVENT, true);
}
// Called when the window size changes
"window_did_resize" => {
window_move_event();
if let Ok(size) = main_window.inner_size() {
let _ = main_window.emit_to(target, WINDOW_RESIZED_EVENT, size);
}
}
// Called when the window position changes
"window_did_move" => window_move_event(),
_ => (),
}
}));
// Set the delegate object for the window to handle window events // Set the delegate object for the window to handle window events
panel.set_event_handler(Some(handler.as_ref())); panel.set_delegate(delegate);
} }

View File

@@ -82,14 +82,6 @@ pub(crate) async fn backend_setup(tauri_app_handle: AppHandle, app_lang: String)
.expect("global tauri AppHandle already initialized"); .expect("global tauri AppHandle already initialized");
log::trace!("global Tauri AppHandle set"); 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(); let registry = SearchSourceRegistry::default();
tauri_app_handle.manage(registry); // Store registry in Tauri's app state tauri_app_handle.manage(registry); // Store registry in Tauri's app state
@@ -107,12 +99,6 @@ pub(crate) async fn backend_setup(tauri_app_handle: AppHandle, app_lang: String)
}) })
.expect("failed to run this closure on the main thread"); .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; crate::init(&tauri_app_handle).await;
if let Err(err) = crate::extension::init_extensions(&tauri_app_handle).await { if let Err(err) = crate::extension::init_extensions(&tauri_app_handle).await {
@@ -124,6 +110,8 @@ pub(crate) async fn backend_setup(tauri_app_handle: AppHandle, app_lang: String)
autostart::ensure_autostart_state_consistent(&tauri_app_handle).unwrap(); autostart::ensure_autostart_state_consistent(&tauri_app_handle).unwrap();
update_app_lang(app_lang).await;
// Invoked, now update the state // Invoked, now update the state
BACKEND_SETUP_COMPLETED.store(true, Ordering::Relaxed); 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. /// Helper getter method to handle the `None` case.
pub(crate) async fn get_app_lang() -> Lang { pub(crate) async fn get_app_lang() -> Lang {
let opt_lang = *APP_LANG.read().await; let opt_lang = *APP_LANG.read().await;
opt_lang.expect("frontend code did not invoke [backend_setup()] to set the APP_LANG") opt_lang.expect("frontend code did not invoke [update_app_lang()] to set the APP_LANG")
} }

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