21 Commits

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

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

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

This commit refactors the implementation to accept both.

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

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

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

* refactor: update

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

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

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

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

> Extension plugin.json

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

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

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

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

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

* docs: update notes

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

* docs: update changelog

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

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

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

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

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

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

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

[1]: https://github.com/ahkohd/tauri-nspanel/issues/76
2025-08-01 15:28:11 +08:00
ayangweb
5c6cf18139 chore: revert microphone permission (#852) 2025-08-01 12:53:38 +08:00
ayangweb
01c31d884a fix: fix microphone permission issue (#851) 2025-08-01 11:02:25 +08:00
57 changed files with 1406 additions and 981 deletions

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

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

View File

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

View File

@@ -17,10 +17,14 @@ Information about release notes of Coco App is provided here.
- feat: support installing local extensions #749
- feat: support sending files in chat messages #764
- feat: sub extension can set 'platforms' now #847
- feat: add extension uninstall option in settings #855
- feat: impl extension settings 'hide_before_open' #862
- feat: index both en/zh_CN app names and show app name in chosen language #875
### 🐛 Bug fix
- fix: fix issue with update check failure #833
- fix: web component login state #857
### ✈️ Improvements
@@ -30,6 +34,11 @@ Information about release notes of Coco App is provided here.
- chore: delete unused code files and dependencies #841
- chore: ignore tauri::AppHandle's generic argument R #845
- refactor: check Extension/plugin.json from all sources #846
- refactor: pinning window won't set CanJoinAllSpaces on macOS #854
- build: web component build error #858
- refactor: coordinate third-party extension operations using lock #867
- refactor: index iOS apps and macOS apps that store icon in Assets.car #872
- refactor: accept both '-' and '_' as locale str separator #876
## 0.7.1 (2025-07-27)

84
src-tauri/Cargo.lock generated
View File

@@ -128,7 +128,7 @@ checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487"
[[package]]
name = "applications"
version = "0.3.1"
source = "git+https://github.com/infinilabs/applications-rs?rev=31b0c030a0f3bc82275fe12debe526153978671d#31b0c030a0f3bc82275fe12debe526153978671d"
source = "git+https://github.com/infinilabs/applications-rs?rev=2f1f88d1880404c5f8d70ad950b859bd49922bee#2f1f88d1880404c5f8d70ad950b859bd49922bee"
dependencies = [
"anyhow",
"core-foundation 0.9.4",
@@ -852,7 +852,6 @@ dependencies = [
"cfg-if",
"chinese-number",
"chrono",
"cocoa 0.24.1",
"derive_more 2.0.1",
"dirs 5.0.1",
"enigo",
@@ -880,6 +879,7 @@ dependencies = [
"serde_plain",
"strsim 0.10.0",
"strum",
"sys-locale",
"sysinfo",
"tauri",
"tauri-build",
@@ -916,22 +916,6 @@ dependencies = [
"zip 4.0.0",
]
[[package]]
name = "cocoa"
version = "0.24.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f425db7937052c684daec3bd6375c8abe2d146dca4b8b143d6db777c39138f3a"
dependencies = [
"bitflags 1.3.2",
"block",
"cocoa-foundation 0.1.2",
"core-foundation 0.9.4",
"core-graphics 0.22.3",
"foreign-types 0.3.2",
"libc",
"objc",
]
[[package]]
name = "cocoa"
version = "0.26.0"
@@ -940,28 +924,14 @@ checksum = "f79398230a6e2c08f5c9760610eb6924b52aa9e7950a619602baba59dcbbdbb2"
dependencies = [
"bitflags 2.9.0",
"block",
"cocoa-foundation 0.2.0",
"cocoa-foundation",
"core-foundation 0.10.0",
"core-graphics 0.24.0",
"core-graphics",
"foreign-types 0.5.0",
"libc",
"objc",
]
[[package]]
name = "cocoa-foundation"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c6234cbb2e4c785b456c0644748b1ac416dd045799740356f8363dfe00c93f7"
dependencies = [
"bitflags 1.3.2",
"block",
"core-foundation 0.9.4",
"core-graphics-types 0.1.3",
"libc",
"objc",
]
[[package]]
name = "cocoa-foundation"
version = "0.2.0"
@@ -971,7 +941,7 @@ dependencies = [
"bitflags 2.9.0",
"block",
"core-foundation 0.10.0",
"core-graphics-types 0.2.0",
"core-graphics-types",
"libc",
"objc",
]
@@ -1088,19 +1058,6 @@ version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "core-graphics"
version = "0.22.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2581bbab3b8ffc6fcbd550bf46c355135d16e9ff2a6ea032ad6b9bf1d7efe4fb"
dependencies = [
"bitflags 1.3.2",
"core-foundation 0.9.4",
"core-graphics-types 0.1.3",
"foreign-types 0.3.2",
"libc",
]
[[package]]
name = "core-graphics"
version = "0.24.0"
@@ -1109,22 +1066,11 @@ checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1"
dependencies = [
"bitflags 2.9.0",
"core-foundation 0.10.0",
"core-graphics-types 0.2.0",
"core-graphics-types",
"foreign-types 0.5.0",
"libc",
]
[[package]]
name = "core-graphics-types"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf"
dependencies = [
"bitflags 1.3.2",
"core-foundation 0.9.4",
"libc",
]
[[package]]
name = "core-graphics-types"
version = "0.2.0"
@@ -1528,8 +1474,8 @@ version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67fd9ae1736d6ebb2e472740fbee86fb2178b8d56feb98a6751411d4c95b7e72"
dependencies = [
"cocoa 0.26.0",
"core-graphics 0.24.0",
"cocoa",
"core-graphics",
"dunce",
"gdk",
"gdkx11",
@@ -1618,7 +1564,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cf6f550bbbdd5fe66f39d429cb2604bcdacbf00dca0f5bbe2e9306a0009b7c6"
dependencies = [
"core-foundation 0.10.0",
"core-graphics 0.24.0",
"core-graphics",
"foreign-types-shared 0.3.1",
"libc",
"log",
@@ -3256,9 +3202,9 @@ dependencies = [
[[package]]
name = "libc"
version = "0.2.172"
version = "0.2.175"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa"
checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543"
[[package]]
name = "libdbus-sys"
@@ -5777,7 +5723,7 @@ checksum = "18051cdd562e792cad055119e0cdb2cfc137e44e3987532e0f9659a77931bb08"
dependencies = [
"bytemuck",
"cfg_aliases",
"core-graphics 0.24.0",
"core-graphics",
"foreign-types 0.5.0",
"js-sys",
"log",
@@ -6024,7 +5970,7 @@ checksum = "1e59c1f38e657351a2e822eadf40d6a2ad4627b9c25557bc1180ec1b3295ef82"
dependencies = [
"bitflags 2.9.0",
"core-foundation 0.10.0",
"core-graphics 0.24.0",
"core-graphics",
"crossbeam-channel",
"dispatch",
"dlopen2",
@@ -6222,9 +6168,9 @@ source = "git+https://github.com/ahkohd/tauri-nspanel?branch=v2#d4b9df797959f8fa
dependencies = [
"bitflags 2.9.0",
"block",
"cocoa 0.26.0",
"cocoa",
"core-foundation 0.10.0",
"core-graphics 0.24.0",
"core-graphics",
"objc",
"objc-foundation",
"objc_id",

View File

@@ -61,7 +61,7 @@ tauri-plugin-drag = "2"
tauri-plugin-macos-permissions = "2"
tauri-plugin-fs-pro = "2"
tauri-plugin-screenshots = "2"
applications = { git = "https://github.com/infinilabs/applications-rs", rev = "31b0c030a0f3bc82275fe12debe526153978671d" }
applications = { git = "https://github.com/infinilabs/applications-rs", rev = "2f1f88d1880404c5f8d70ad950b859bd49922bee" }
tokio-native-tls = "0.3" # For wss connections
tokio = { version = "1", features = ["full"] }
tokio-tungstenite = { version = "0.20", features = ["native-tls"] }
@@ -107,10 +107,10 @@ cfg-if = "1.0.1"
sysinfo = "0.35.2"
indexmap = { version = "2.10.0", features = ["serde"] }
strum = { version = "0.27.2", features = ["derive"] }
sys-locale = "0.3.2"
[target."cfg(target_os = \"macos\")".dependencies]
tauri-nspanel = { git = "https://github.com/ahkohd/tauri-nspanel", branch = "v2" }
cocoa = "0.24"
[target."cfg(any(target_os = \"macos\", windows, target_os = \"linux\"))".dependencies]
tauri-plugin-single-instance = { version = "2.0.0", features = ["deep-link"] }

View File

@@ -69,6 +69,7 @@
"updater:default",
"windows-version:default",
"log:default",
"opener:default"
"opener:default",
"core:window:allow-unminimize"
]
}

View File

@@ -1,15 +1,15 @@
use std::{fs::create_dir, io::Read};
use tauri::Manager;
use tauri::{AppHandle, Manager};
use tauri_plugin_autostart::ManagerExt;
/// If the state reported from the OS and the state stored by us differ, our state is
/// prioritized and seen as the correct one. Update the OS state to make them consistent.
pub fn ensure_autostart_state_consistent(app: &mut tauri::App) -> Result<(), String> {
let autostart_manager = app.autolaunch();
pub fn ensure_autostart_state_consistent(tauri_app_handle: &AppHandle) -> Result<(), String> {
let autostart_manager = tauri_app_handle.autolaunch();
let os_state = autostart_manager.is_enabled().map_err(|e| e.to_string())?;
let coco_stored_state = current_autostart(app.app_handle()).map_err(|e| e.to_string())?;
let coco_stored_state = current_autostart(tauri_app_handle).map_err(|e| e.to_string())?;
if os_state != coco_stored_state {
log::warn!(

View File

@@ -1,3 +1,4 @@
use crate::extension::ExtensionSettings;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use tauri::AppHandle;
@@ -30,17 +31,40 @@ pub struct EditorInfo {
pub timestamp: Option<String>,
}
/// Defines the action that would be performed when a document gets opened.
/// Defines the action that would be performed when a [document](Document) gets opened.
///
/// "Document" is a uniform type that the backend uses to send the search results
/// back to the frontend. Since Coco can search many sources, "Document" can
/// represent different things, application, web page, local file, extensions, and
/// so on. Each has its own specific open action.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) enum OnOpened {
/// Launch the application
Application { app_path: String },
/// Open the URL.
Document { url: String },
/// The document is an extension.
Extension(ExtensionOnOpened),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct ExtensionOnOpened {
/// Different types of extensions have different open behaviors.
pub(crate) ty: ExtensionOnOpenedType,
/// Extensions settings. Some could affect open action.
///
/// Optional because not all extensions have their settings.
pub(crate) settings: Option<ExtensionSettings>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) enum ExtensionOnOpenedType {
/// Spawn a child process to run the `CommandAction`.
Command {
action: crate::extension::CommandAction,
},
/// Open the `link`.
//
// NOTE that this variant has the same definition as `struct Quicklink`, but we
// cannot use it directly, its `link` field should be deserialized/serialized
// from/to a string, but we need a JSON object here.
@@ -57,20 +81,24 @@ impl OnOpened {
match self {
Self::Application { app_path } => app_path.clone(),
Self::Document { url } => url.clone(),
Self::Command { action } => {
const WHITESPACE: &str = " ";
let mut ret = action.exec.clone();
ret.push_str(WHITESPACE);
if let Some(ref args) = action.args {
ret.push_str(args.join(WHITESPACE).as_str());
}
Self::Extension(ext_on_opened) => {
match &ext_on_opened.ty {
ExtensionOnOpenedType::Command { action } => {
const WHITESPACE: &str = " ";
let mut ret = action.exec.clone();
ret.push_str(WHITESPACE);
if let Some(ref args) = action.args {
ret.push_str(args.join(WHITESPACE).as_str());
}
ret
ret
}
// Currently, our URL is static and does not support dynamic parameters.
// The URL of a quicklink is nearly useless without such dynamic user
// inputs, so until we have dynamic URL support, we just use "N/A".
ExtensionOnOpenedType::Quicklink { .. } => String::from("N/A"),
}
}
// Currently, our URL is static and does not support dynamic parameters.
// The URL of a quicklink is nearly useless without such dynamic user
// inputs, so until we have dynamic URL support, we just use "N/A".
Self::Quicklink { .. } => String::from("N/A"),
}
}
}
@@ -95,65 +123,78 @@ pub(crate) async fn open(
homemade_tauri_shell_open(tauri_app_handle.clone(), url).await?
}
OnOpened::Command { action } => {
log::debug!("open (execute) command [{:?}]", action);
let mut cmd = Command::new(action.exec);
if let Some(args) = action.args {
cmd.args(args);
}
let output = cmd.output().map_err(|e| e.to_string())?;
// Sometimes, we wanna see the result in logs even though it doesn't fail.
log::debug!(
"executing open(Command) result, exit code: [{}], stdout: [{}], stderr: [{}]",
output.status,
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
if !output.status.success() {
log::warn!(
"executing open(Command) failed, exit code: [{}], stdout: [{}], stderr: [{}]",
output.status,
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
return Err(format!(
"Command failed, stderr [{}]",
String::from_utf8_lossy(&output.stderr)
));
}
}
OnOpened::Quicklink {
link,
open_with: opt_open_with,
} => {
let url = link.concatenate_url(&extra_args);
log::debug!("open quicklink [{}] with [{:?}]", url, opt_open_with);
cfg_if::cfg_if! {
// The `open_with` functionality is only supported on macOS, provided
// by the `open -a` command.
if #[cfg(target_os = "macos")] {
let mut cmd = Command::new("open");
if let Some(ref open_with) = opt_open_with {
cmd.arg("-a");
cmd.arg(open_with.as_str());
OnOpened::Extension(ext_on_opened) => {
// Apply the settings that would affect open behavior
if let Some(settings) = ext_on_opened.settings {
if let Some(should_hide) = settings.hide_before_open {
if should_hide {
crate::hide_coco(tauri_app_handle.clone()).await;
}
cmd.arg(&url);
}
}
let output = cmd.output().map_err(|e| format!("failed to spawn [open] due to error [{}]", e))?;
match ext_on_opened.ty {
ExtensionOnOpenedType::Command { action } => {
log::debug!("open (execute) command [{:?}]", action);
if !output.status.success() {
return Err(format!(
"failed to open with app {:?}: {}",
opt_open_with,
let mut cmd = Command::new(action.exec);
if let Some(args) = action.args {
cmd.args(args);
}
let output = cmd.output().map_err(|e| e.to_string())?;
// Sometimes, we wanna see the result in logs even though it doesn't fail.
log::debug!(
"executing open(Command) result, exit code: [{}], stdout: [{}], stderr: [{}]",
output.status,
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
));
);
if !output.status.success() {
log::warn!(
"executing open(Command) failed, exit code: [{}], stdout: [{}], stderr: [{}]",
output.status,
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
return Err(format!(
"Command failed, stderr [{}]",
String::from_utf8_lossy(&output.stderr)
));
}
}
ExtensionOnOpenedType::Quicklink {
link,
open_with: opt_open_with,
} => {
let url = link.concatenate_url(&extra_args);
log::debug!("open quicklink [{}] with [{:?}]", url, opt_open_with);
cfg_if::cfg_if! {
// The `open_with` functionality is only supported on macOS, provided
// by the `open -a` command.
if #[cfg(target_os = "macos")] {
let mut cmd = Command::new("open");
if let Some(ref open_with) = opt_open_with {
cmd.arg("-a");
cmd.arg(open_with.as_str());
}
cmd.arg(&url);
let output = cmd.output().map_err(|e| format!("failed to spawn [open] due to error [{}]", e))?;
if !output.status.success() {
return Err(format!(
"failed to open with app {:?}: {}",
opt_open_with,
String::from_utf8_lossy(&output.stderr)
));
}
} else {
homemade_tauri_shell_open(tauri_app_handle.clone(), url).await?
}
}
} else {
homemade_tauri_shell_open(tauri_app_handle.clone(), url).await?
}
}
}

View File

@@ -28,7 +28,7 @@ use serde_json::Value as Json;
use std::path::Path;
use std::path::PathBuf;
use tauri::{AppHandle, Manager, async_runtime};
use tauri_plugin_fs_pro::{IconOptions, icon, metadata, name};
use tauri_plugin_fs_pro::{IconOptions, icon, metadata};
use tauri_plugin_global_shortcut::GlobalShortcutExt;
use tauri_plugin_global_shortcut::Shortcut;
use tauri_plugin_global_shortcut::ShortcutEvent;
@@ -36,7 +36,13 @@ use tauri_plugin_global_shortcut::ShortcutState;
use tauri_plugin_store::StoreExt;
use tokio::sync::oneshot::Sender as OneshotSender;
// Deprecated. We no longer index this field, but to be backward-compatible, we
// have to keep it.
const FIELD_APP_NAME: &str = "app_name";
const FIELD_APP_NAME_IN_SYSTEM_LANG: &str = "app_name_in_system_lang";
const FIELD_APP_NAME_ZH: &str = "app_name_zh";
const FIELD_APP_NAME_EN: &str = "app_name_en";
const FIELD_ICON_PATH: &str = "icon_path";
const FIELD_APP_ALIAS: &str = "app_alias";
const APPLICATION_SEARCH_SOURCE_ID: &str = "application";
@@ -58,37 +64,18 @@ const INDEX_DIR: &str = "local_application_index";
pub(crate) const QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME: &str = "Applications";
pub fn get_default_search_paths() -> Vec<String> {
#[cfg(target_os = "macos")]
{
let home_dir =
PathBuf::from(std::env::var_os("HOME").expect("environment variable $HOME not found"));
return vec![
"/Applications".into(),
"/System/Applications".into(),
"/System/Library/CoreServices".into(),
home_dir
.join("Applications")
.into_os_string()
.into_string()
.expect("this path should be UTF-8 encoded"),
];
let paths = applications::get_default_search_paths();
let mut ret = Vec::with_capacity(paths.len());
for search_path in paths {
let path_string = search_path
.into_os_string()
.into_string()
.expect("path should be UTF-8 encoded");
ret.push(path_string);
}
#[cfg(not(target_os = "macos"))]
{
let paths = applications::get_default_search_paths();
let mut ret = Vec::with_capacity(paths.len());
for search_path in paths {
let path_string = search_path
.into_os_string()
.into_string()
.expect("path should be UTF-8 encoded");
ret.push(path_string);
}
ret
}
ret
}
/// Helper function to return `app`'s path.
@@ -115,16 +102,56 @@ fn get_app_path(app: &App) -> String {
.expect("should be UTF-8 encoded")
}
/// Helper function to return `app`'s path.
///
/// * macOS: extract `app_path`'s file name and remove the file extension
/// * Windows/Linux: return the name specified in `.desktop` file
async fn get_app_name(app: &App) -> String {
if cfg!(any(target_os = "linux", target_os = "windows")) {
app.name.clone()
/// Helper function to return `app`'s Chinese name.
async fn get_app_name_zh(app: &App) -> String {
// zh_CN or zh-CN
if let Some(name) = app.localized_app_names.get("zh_CN") {
return name.clone();
}
if let Some(name) = app.localized_app_names.get("zh-CN") {
return name.clone();
}
// zh_Hans or zh-Hans
if let Some(name) = app.localized_app_names.get("zh_Hans") {
return name.clone();
}
if let Some(name) = app.localized_app_names.get("zh-Hans") {
return name.clone();
}
// Fall back to base name
app.name.clone()
}
/// Helper function to return `app`'s English name.
async fn get_app_name_en(app: &App) -> String {
// en_US or en-US
if let Some(name) = app.localized_app_names.get("en_US") {
return name.clone();
}
if let Some(name) = app.localized_app_names.get("en-US") {
return name.clone();
}
// English (General)
if let Some(name) = app.localized_app_names.get("en") {
return name.clone();
}
// Fall back to base name
app.name.clone()
}
/// Helper function to return `app`'s name in system language.
async fn get_app_name_in_system_lang(app: &App) -> String {
let system_lang = crate::util::system_lang::get_system_lang();
if let Some(name) = app.localized_app_names.get(&system_lang) {
name.clone()
} else {
let app_path = get_app_path(app);
name(app_path.into()).await
// Fall back to base name
app.name.clone()
}
}
@@ -221,9 +248,17 @@ async fn index_applications_if_not_indexed(
pizza_engine_builder.set_data_store(disk_store);
let mut schema = Schema::new();
let field_app_name = Property::builder(FieldType::Text).build();
let field_app_name_zh = Property::builder(FieldType::Text).build();
schema
.add_property(FIELD_APP_NAME, field_app_name)
.add_property(FIELD_APP_NAME_ZH, field_app_name_zh)
.expect("no collision could happen");
let field_app_name_en = Property::builder(FieldType::Text).build();
schema
.add_property(FIELD_APP_NAME_EN, field_app_name_en)
.expect("no collision could happen");
let field_app_name_in_system_lang = Property::builder(FieldType::Text).build();
schema
.add_property(FIELD_APP_NAME_IN_SYSTEM_LANG, field_app_name_in_system_lang)
.expect("no collision could happen");
let property_icon = Property::builder(FieldType::Text).index(false).build();
schema
@@ -268,21 +303,39 @@ async fn index_applications_if_not_indexed(
for app in apps.iter() {
let app_path = get_app_path(app);
let app_name = get_app_name(app).await;
let app_name_zh = get_app_name_zh(app).await;
let app_name_en = get_app_name_en(app).await;
let app_name_in_system_lang = get_app_name_in_system_lang(app).await;
let app_icon_path = get_app_icon_path(&tauri_app_handle, app)
.await
.map_err(|str| anyhow::anyhow!(str))?;
let app_alias = get_app_alias(&tauri_app_handle, &app_path).unwrap_or(String::new());
if app_name.is_empty() || app_name.eq(&tauri_app_handle.package_info().name) {
// Skip if all names are empty
if app_name_zh.is_empty()
&& app_name_en.is_empty()
&& app_name_in_system_lang.is_empty()
{
continue;
}
// Skip if this is Coco itself
//
// Coco does not have localized app names, so app_name_en and app_name_zh
// should both have value "Coco-AI", so either should work.
if app_name_en == tauri_app_handle.package_info().name {
continue;
}
// You cannot write `app_name.clone()` within the `doc!()` macro, we should fix this.
let app_name_clone = app_name.clone();
let app_name_zh_clone = app_name_zh.clone();
let app_name_en_clone = app_name_en.clone();
let app_name_in_system_lang = app_name_in_system_lang.clone();
let app_path_clone = app_path.clone();
let document = doc!( app_path_clone, {
FIELD_APP_NAME => app_name_clone,
FIELD_APP_NAME_ZH => app_name_zh_clone,
FIELD_APP_NAME_EN => app_name_en_clone,
FIELD_APP_NAME_IN_SYSTEM_LANG => app_name_in_system_lang,
FIELD_ICON_PATH => app_icon_path,
FIELD_APP_ALIAS => app_alias,
}
@@ -291,8 +344,8 @@ async fn index_applications_if_not_indexed(
// We don't error out because one failure won't break the whole thing
if let Err(e) = writer.create_document(document).await {
warn!(
"failed to index application [app name: '{}', app path: '{}'] due to error [{}]",
app_name, app_path, e
"failed to index application [app name zh: '{}', app name en: '{}', app path: '{}'] due to error [{}]",
app_name_zh, app_name_en, app_path, e
)
}
}
@@ -421,9 +474,19 @@ impl Task for SearchApplicationsTask {
//
// It will be passed to Pizza like "Google\nChrome". Using Display impl would result
// in an invalid query DSL and serde will complain.
//
// In order to be backward compatible, we still do match and prefix queries to the
// app_name field.
let dsl = format!(
"{{ \"query\": {{ \"bool\": {{ \"should\": [ {{ \"match\": {{ \"{FIELD_APP_NAME}\": {:?} }} }}, {{ \"prefix\": {{ \"{FIELD_APP_NAME}\": {:?} }} }} ] }} }} }}",
self.query_string, self.query_string
"{{ \"query\": {{ \"bool\": {{ \"should\": [ {{ \"match\": {{ \"{FIELD_APP_NAME_ZH}\": {:?} }} }}, {{ \"prefix\": {{ \"{FIELD_APP_NAME_ZH}\": {:?} }} }}, {{ \"match\": {{ \"{FIELD_APP_NAME_EN}\": {:?} }} }}, {{ \"prefix\": {{ \"{FIELD_APP_NAME_EN}\": {:?} }} }}, {{ \"match\": {{ \"{FIELD_APP_NAME_IN_SYSTEM_LANG}\": {:?} }} }}, {{ \"prefix\": {{ \"{FIELD_APP_NAME_IN_SYSTEM_LANG}\": {:?} }} }}, {{ \"match\": {{ \"{FIELD_APP_NAME}\": {:?} }} }}, {{ \"prefix\": {{ \"{FIELD_APP_NAME}\": {:?} }} }} ] }} }} }}",
self.query_string,
self.query_string,
self.query_string,
self.query_string,
self.query_string,
self.query_string,
self.query_string,
self.query_string
);
let state = state
@@ -620,7 +683,7 @@ impl SearchSource for ApplicationSearchSource {
let total_hits = search_result.total_hits;
let source = self.get_type();
let hits = pizza_engine_hits_to_coco_hits(search_result.hits);
let hits = pizza_engine_hits_to_coco_hits(search_result.hits).await;
Ok(QueryResponse {
source,
@@ -630,9 +693,11 @@ impl SearchSource for ApplicationSearchSource {
}
}
fn pizza_engine_hits_to_coco_hits(
async fn pizza_engine_hits_to_coco_hits(
pizza_engine_hits: Option<Vec<PizzaEngineDocument>>,
) -> Vec<(Document, f64)> {
use crate::util::app_lang::{Lang, get_app_lang};
let Some(engine_hits) = pizza_engine_hits else {
return Vec::new();
};
@@ -641,10 +706,43 @@ fn pizza_engine_hits_to_coco_hits(
for engine_hit in engine_hits {
let score = engine_hit.score.unwrap_or(0.0) as f64;
let mut document_fields = engine_hit.fields;
let app_name = match document_fields.remove(FIELD_APP_NAME).unwrap() {
FieldValue::Text(string) => string,
_ => unreachable!("field name is of type Text"),
// Get both Chinese and English names
let opt_app_name_zh = match document_fields.remove(FIELD_APP_NAME_ZH) {
Some(FieldValue::Text(string)) => Some(string),
_ => None,
};
let opt_app_name_en = match document_fields.remove(FIELD_APP_NAME_EN) {
Some(FieldValue::Text(string)) => Some(string),
_ => None,
};
let opt_app_name_deprecated = match document_fields.remove(FIELD_APP_NAME) {
Some(FieldValue::Text(string)) => Some(string),
_ => None,
};
let app_name: String = {
if let Some(legacy_app_name) = opt_app_name_deprecated {
// Old version of index, which only contains the field app_name.
legacy_app_name
} else {
// New version of index store the following 2 fields
let panic_msg = format!(
"new version of index should contain field [{}] and [{}]",
FIELD_APP_NAME_EN, FIELD_APP_NAME_ZH
);
let app_name_zh = opt_app_name_zh.expect(&panic_msg);
let app_name_en = opt_app_name_en.expect(&panic_msg);
// Choose the appropriate name based on current language
match get_app_lang().await {
Lang::zh_CN => app_name_zh,
Lang::en_US => app_name_en,
}
}
};
let app_path = engine_hit.key.expect("key should be set to app path");
let app_icon_path = match document_fields.remove(FIELD_ICON_PATH).unwrap() {
FieldValue::Text(string) => string,
@@ -664,7 +762,7 @@ fn pizza_engine_hits_to_coco_hits(
}),
id: app_path.clone(),
category: Some("Application".to_string()),
title: Some(app_name.clone()),
title: Some(app_name),
icon: Some(app_icon_path),
on_opened: Some(on_opened),
url: Some(url),
@@ -1052,15 +1150,24 @@ pub async fn get_app_search_path(tauri_app_handle: AppHandle) -> Vec<String> {
#[tauri::command]
pub async fn get_app_list(tauri_app_handle: AppHandle) -> Result<Vec<Extension>, String> {
use crate::util::app_lang::{Lang, get_app_lang};
let search_paths = get_app_search_path(tauri_app_handle.clone()).await;
let apps = list_app_in(search_paths)?;
let mut app_entries = Vec::with_capacity(apps.len());
let lang = get_app_lang().await;
for app in apps {
let name = get_app_name(&app).await;
let name = match lang {
Lang::zh_CN => get_app_name_zh(&app).await,
Lang::en_US => get_app_name_en(&app).await,
};
// filter out Coco-AI
//
// Coco does not have localized app names, so regardless the chosen language, name
// should have value "Coco-AI".
if name.eq(&tauri_app_handle.package_info().name) {
continue;
}

View File

@@ -1,6 +1,8 @@
pub(crate) mod built_in;
pub(crate) mod third_party;
use crate::common::document::ExtensionOnOpened;
use crate::common::document::ExtensionOnOpenedType;
use crate::common::document::OnOpened;
use crate::common::register::SearchSourceRegistry;
use crate::util::platform::Platform;
@@ -99,7 +101,7 @@ pub struct Extension {
/// Extension settings
#[serde(skip_serializing_if = "Option::is_none")]
settings: Option<Json>,
settings: Option<ExtensionSettings>,
// We do not care about these fields, just take it regardless of what it is.
screenshots: Option<Json>,
@@ -164,37 +166,57 @@ impl<'ext> PartialEq<ExtensionBundleId> for ExtensionBundleIdBorrowed<'ext> {
}
impl Extension {
/// WARNING: the bundle ID returned from this function always has its `sub_extension_id`
/// set to `None`, this may not be what you want.
pub(crate) fn bundle_id_borrowed(&self) -> ExtensionBundleIdBorrowed<'_> {
ExtensionBundleIdBorrowed {
developer: self.developer.as_deref(),
extension_id: &self.id,
sub_extension_id: None,
}
}
/// Whether this extension could be searched.
pub(crate) fn searchable(&self) -> bool {
self.on_opened().is_some()
}
/// Return what will happen when we open this extension.
///
/// `None` if it cannot be opened.
pub(crate) fn on_opened(&self) -> Option<OnOpened> {
let settings = self.settings.clone();
match self.r#type {
ExtensionType::Group => None,
ExtensionType::Extension => None,
ExtensionType::Command => Some(OnOpened::Command {
action: self.action.clone().unwrap_or_else(|| {
panic!(
"Command extension [{}]'s [action] field is not set, something wrong with your extension validity check", self.id
)
}),
}),
ExtensionType::Application => Some(OnOpened::Application {
app_path: self.id.clone(),
}),
// This function, at the time of writing this comment, is primarily
// used by third-party extensions.
//
// Built-in extensions don't use this as they are technically not
// "struct Extension"s. Typically, they directly construct a
// "struct Document" from their own type.
ExtensionType::Calculator => unreachable!("this is handled by frontend"),
ExtensionType::AiExtension => unreachable!(
"currently, all AI extensions we have are non-searchable, so we won't open them"
),
ExtensionType::Application => {
// We can have a impl like:
//
// Some(OnOpened::Application { app_path: self.id.clone() })
//
// but it won't be used.
unreachable!(
"Applications are not \"struct Extension\" under the hood, they won't call this method"
)
}
// These 2 types of extensions cannot be opened
ExtensionType::Group => return None,
ExtensionType::Extension => return None,
ExtensionType::Command => {
let ty = ExtensionOnOpenedType::Command {
action: self.action.clone().unwrap_or_else(|| {
panic!(
"Command extension [{}]'s [action] field is not set, something wrong with your extension validity check", self.id
)
}),
};
let extension_on_opened = ExtensionOnOpened { ty, settings };
Some(OnOpened::Extension(extension_on_opened))
}
ExtensionType::Quicklink => {
let quicklink = self.quicklink.clone().unwrap_or_else(|| {
panic!(
@@ -202,15 +224,17 @@ impl Extension {
)
});
Some(OnOpened::Quicklink{
link: quicklink.link,
open_with: quicklink.open_with,
})
let ty = ExtensionOnOpenedType::Quicklink {
link: quicklink.link,
open_with: quicklink.open_with,
};
let extension_on_opened = ExtensionOnOpened { ty, settings };
Some(OnOpened::Extension(extension_on_opened))
}
ExtensionType::Script => todo!("not supported yet"),
ExtensionType::Setting => todo!("not supported yet"),
ExtensionType::Calculator => None,
ExtensionType::AiExtension => None,
}
}
@@ -587,10 +611,6 @@ fn filter_out_extensions(
}
}
/// Return value:
///
/// * boolean: indicates if we found any invalid extensions
/// * Vec<Extension>: loaded extensions
#[tauri::command]
pub(crate) async fn list_extensions(
tauri_app_handle: AppHandle,
@@ -598,16 +618,11 @@ pub(crate) async fn list_extensions(
extension_type: Option<ExtensionType>,
list_enabled: bool,
) -> Result<Vec<Extension>, String> {
log::trace!("loading extensions");
let third_party_dir = third_party::get_third_party_extension_directory(&tauri_app_handle);
if !third_party_dir.try_exists().map_err(|e| e.to_string())? {
tokio::fs::create_dir_all(&third_party_dir)
.await
.map_err(|e| e.to_string())?;
}
let mut third_party_extensions =
third_party::list_third_party_extensions(&third_party_dir).await?;
let mut third_party_extensions = third_party::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE
.get()
.expect("global third party extension search source not set")
.extensions_snapshot()
.await;
let built_in_extensions = built_in::list_built_in_extensions(&tauri_app_handle).await?;
@@ -661,31 +676,54 @@ pub(crate) async fn list_extensions(
Ok(extensions)
}
pub(crate) async fn init_extensions(
tauri_app_handle: AppHandle,
mut extensions: Vec<Extension>,
) -> Result<(), String> {
/// Initialize all the things that are related to extensions.
pub(crate) async fn init_extensions(tauri_app_handle: &AppHandle) -> Result<(), String> {
log::trace!("initializing extensions");
let search_source_registry_tauri_state = tauri_app_handle.state::<SearchSourceRegistry>();
// Third-party extensions
//
// 1. Init the global search source variable
// 2. Init the extensions in search source
// 3. Register the search source
let third_party_dir = third_party::get_third_party_extension_directory(&tauri_app_handle);
if !third_party_dir.try_exists().map_err(|e| e.to_string())? {
tokio::fs::create_dir_all(&third_party_dir)
.await
.map_err(|e| e.to_string())?;
}
let extensions =
third_party::load_third_party_extensions_from_directory(&third_party_dir).await?;
let search_source = third_party::ThirdPartyExtensionsSearchSource::new(extensions);
search_source.init(&tauri_app_handle).await.unwrap();
THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE
.set(search_source.clone())
.unwrap_or_else(|_already_set| {
panic!(
"while trying to set the global third party extension search source variable {}, we found it is already set, which should not happen",
"THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE"
)
});
search_source_registry_tauri_state
.register_source(search_source)
.await;
// Extension store
search_source_registry_tauri_state
.register_source(third_party::install::store::ExtensionStore)
.await;
// Built-in extensions
// Built-in extension: Application
built_in::application::ApplicationSearchSource::prepare_index_and_store(
tauri_app_handle.clone(),
)
.await?;
// extension store
search_source_registry_tauri_state
.register_source(third_party::install::store::ExtensionStore)
.await;
// Init the built-in enabled extensions
for built_in_extension in extensions
.extract_if(.., |ext| {
built_in::is_extension_built_in(&ext.bundle_id_borrowed())
})
.filter(|ext| ext.enabled)
{
let built_in_extensions = built_in::list_built_in_extensions(&tauri_app_handle).await?;
for built_in_extension in built_in_extensions.iter().filter(|ext| ext.enabled) {
built_in::init_built_in_extension(
&tauri_app_handle,
&built_in_extension,
@@ -694,18 +732,6 @@ pub(crate) async fn init_extensions(
.await?;
}
// Now the third-party extensions
let third_party_search_source = third_party::ThirdPartyExtensionsSearchSource::new(extensions);
third_party_search_source.init(&tauri_app_handle).await?;
let third_party_search_source_clone = third_party_search_source.clone();
// Set the global search source so that we can access it in `#[tauri::command]`s
// ignore the result because this function will be invoked twice, which
// means this global variable will be set twice.
let _ = THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE.set(third_party_search_source_clone);
search_source_registry_tauri_state
.register_source(third_party_search_source)
.await;
Ok(())
}
@@ -1078,6 +1104,13 @@ fn parse_dynamic_placeholder(content: &str) -> Result<QuicklinkLinkComponent, St
})
}
/// Built-in extension settings
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub(crate) struct ExtensionSettings {
/// If set, Coco main window would hide before opening this document/e
pub(crate) hide_before_open: Option<bool>,
}
#[cfg(test)]
mod tests {
use super::*;

View File

@@ -104,6 +104,16 @@ fn check_main_extension_only(extension: &Extension) -> Result<(), String> {
}
}
if extension.settings.is_some() {
// Sub-extensions are all searchable, so this check is only for main extensions.
if !extension.searchable() {
return Err(format!(
"invalid extension {}, field [settings] is currently only allowed in searchable extension, this type of extension is not searchable [{}]",
extension.id, extension.r#type
));
}
}
Ok(())
}
@@ -204,7 +214,9 @@ fn check_main_extension_or_sub_extension(
#[cfg(test)]
mod tests {
use super::*;
use crate::extension::{CommandAction, Quicklink, QuicklinkLink, QuicklinkLinkComponent};
use crate::extension::{
CommandAction, ExtensionSettings, Quicklink, QuicklinkLink, QuicklinkLinkComponent,
};
/// Helper function to create a basic valid extension
fn create_basic_extension(id: &str, extension_type: ExtensionType) -> Extension {
@@ -309,6 +321,29 @@ mod tests {
.contains("only extension of type [Group] and [Extension] can have sub-extensions")
);
}
#[test]
fn test_non_searchable_extension_set_field_settings() {
let mut extension = create_basic_extension("test-group", ExtensionType::Group);
extension.settings = Some(ExtensionSettings {
hide_before_open: None,
});
let error_msg = general_check(&extension).unwrap_err();
assert!(
error_msg
.contains("field [settings] is currently only allowed in searchable extension")
);
let mut extension = create_basic_extension("test-extension", ExtensionType::Extension);
extension.settings = Some(ExtensionSettings {
hide_before_open: None,
});
let error_msg = general_check(&extension).unwrap_err();
assert!(
error_msg
.contains("field [settings] is currently only allowed in searchable extension")
);
}
/* test_check_main_extension_only */
/* test check_main_extension_or_sub_extension */

View File

@@ -165,6 +165,14 @@ pub(crate) async fn install_local_extension(
// extensions that are not, filter them out.
filter_out_incompatible_sub_extensions(&mut extension, current_platform);
// We are going to modify our third-party extension list, grab the write lock
// to ensure exclusive access.
let mut third_party_ext_list_write_lock = THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE
.get()
.expect("global third party search source not set")
.write_lock()
.await;
// Create destination directory
let dest_dir = get_third_party_extension_directory(&tauri_app_handle)
.join(DEVELOPER_ID_LOCAL)
@@ -215,11 +223,7 @@ pub(crate) async fn install_local_extension(
canonicalize_relative_icon_path(&dest_dir, &mut extension)?;
// Add extension to the search source
THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE
.get()
.unwrap()
.add_extension(extension)
.await;
third_party_ext_list_write_lock.push(extension);
Ok(())
}

View File

@@ -34,7 +34,7 @@ use super::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE;
pub(crate) async fn is_extension_installed(developer: &str, extension_id: &str) -> bool {
THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE
.get()
.unwrap()
.expect("global third party search source not set")
.extension_exists(developer, extension_id)
.await
}

View File

@@ -19,6 +19,7 @@ use crate::extension::third_party::install::filter_out_incompatible_sub_extensio
use crate::server::http_client::HttpClient;
use crate::util::platform::Platform;
use async_trait::async_trait;
use http::Method;
use reqwest::StatusCode;
use serde_json::Map as JsonObject;
use serde_json::Value as Json;
@@ -172,6 +173,52 @@ pub(crate) async fn search_extension(
Ok(extensions)
}
#[tauri::command]
pub(crate) async fn extension_detail(
id: String,
) -> Result<Option<JsonObject<String, Json>>, String> {
let url = format!("http://dev.infini.cloud:27200/store/extension/{}", id);
let response =
HttpClient::send_raw_request(Method::GET, url.as_str(), None, None, None).await?;
if response.status() == StatusCode::NOT_FOUND {
return Ok(None);
}
let response_dbg_str = format!("{:?}", response);
// The response of an ES style GET request
let mut response: JsonObject<String, Json> = response.json().await.unwrap_or_else(|_e| {
panic!(
"response body of [/store/extension/<ID>] is not a JSON object, response [{:?}]",
response_dbg_str
)
});
let source_json = response.remove("_source").unwrap_or_else(|| {
panic!("field [_source] not found in the JSON returned from [/store/extension/<ID>]")
});
let mut source_obj = match source_json {
Json::Object(obj) => obj,
_ => panic!(
"field [_source] should be a JSON object, but it is not, value: [{}]",
source_json
),
};
let developer_id = match &source_obj["developer"]["id"] {
Json::String(dev) => dev,
_ => {
panic!(
"field [_source.developer.id] should be a string, but it is not, value: [{}]",
source_obj["developer"]["id"]
)
}
};
let installed = is_extension_installed(developer_id, &id).await;
source_obj.insert("installed".to_string(), Json::Bool(installed));
Ok(Some(source_obj))
}
#[tauri::command]
pub(crate) async fn install_extension_from_store(
tauri_app_handle: AppHandle,
@@ -250,21 +297,40 @@ pub(crate) async fn install_extension_from_store(
e
);
});
let developer_id = extension.developer.clone().expect("developer has been set");
drop(plugin_json);
general_check(&extension)?;
let current_platform = Platform::current();
if let Some(ref platforms) = extension.platforms {
if !platforms.contains(&current_platform) {
return Err("this extension is not compatible with your OS".into());
}
}
if is_extension_installed(&developer_id, &id).await {
return Err("Extension already installed.".into());
}
// Extension is compatible with current platform, but it could contain sub
// extensions that are not, filter them out.
filter_out_incompatible_sub_extensions(&mut extension, Platform::current());
filter_out_incompatible_sub_extensions(&mut extension, current_platform);
// We are going to modify our third-party extension list, grab the write lock
// to ensure exclusive access.
let mut third_party_ext_list_write_lock = THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE
.get()
.expect("global third party search source not set")
.write_lock()
.await;
// Write extension files to the extension directory
let developer = extension.developer.clone().unwrap_or_default();
let extension_id = extension.id.clone();
let extension_directory = {
let mut path = get_third_party_extension_directory(&tauri_app_handle);
path.push(developer);
path.push(developer_id);
path.push(extension_id.as_str());
path
};
@@ -331,11 +397,7 @@ pub(crate) async fn install_extension_from_store(
// Turn it into an absolute path if it is a valid relative path because frontend code need this.
canonicalize_relative_icon_path(&extension_directory, &mut extension)?;
THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE
.get()
.unwrap()
.add_extension(extension)
.await;
third_party_ext_list_write_lock.push(extension);
Ok(())
}

View File

@@ -20,6 +20,7 @@ use async_trait::async_trait;
use borrowme::ToOwned;
use check::general_check;
use function_name::named;
use std::io::ErrorKind;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
@@ -42,7 +43,7 @@ pub(crate) fn get_third_party_extension_directory(tauri_app_handle: &AppHandle)
app_data_dir
}
pub(crate) async fn list_third_party_extensions(
pub(crate) async fn load_third_party_extensions_from_directory(
directory: &Path,
) -> Result<Vec<Extension>, String> {
let mut extensions_dir_iter = read_dir(&directory).await.map_err(|e| e.to_string())?;
@@ -202,7 +203,7 @@ pub(crate) async fn list_third_party_extensions(
/// Since some `#[tauri::command]`s need to access it, we store it in a global
/// static variable as well.
#[derive(Debug, Clone)]
pub(super) struct ThirdPartyExtensionsSearchSource {
pub(crate) struct ThirdPartyExtensionsSearchSource {
inner: Arc<ThirdPartyExtensionsSearchSourceInner>,
}
@@ -343,6 +344,11 @@ impl ThirdPartyExtensionsSearchSource {
}
}
/// Acquire the write lock to the extension list.
pub(crate) async fn write_lock(&self) -> RwLockWriteGuard<'_, Vec<Extension>> {
self.inner.extensions.write().await
}
#[named]
pub(super) async fn enable_extension(
&self,
@@ -457,7 +463,7 @@ impl ThirdPartyExtensionsSearchSource {
/// Initialize the third-party extensions, which literally means
/// enabling/activating the enabled extensions.
pub(super) async fn init(&self, tauri_app_handle: &AppHandle) -> Result<(), String> {
pub(crate) async fn init(&self, tauri_app_handle: &AppHandle) -> Result<(), String> {
let extensions_read_lock = self.inner.extensions.read().await;
for extension in extensions_read_lock.iter().filter(|ext| ext.enabled) {
@@ -628,48 +634,68 @@ impl ThirdPartyExtensionsSearchSource {
.any(|ext| ext.developer.as_deref() == Some(developer) && ext.id == extension_id)
}
/// Add `extension` to the **in-memory** extension list.
pub(crate) async fn add_extension(&self, extension: Extension) {
assert!(
extension.developer.is_some(),
"loaded third party extension should have its developer set"
);
pub(crate) async fn uninstall_extension(
&self,
tauri_app_handle: &AppHandle,
developer: &str,
extension_id: &str,
) -> Result<(), String> {
let mut write_lock = self.inner.extensions.write().await;
let mut write_lock_guard = self.inner.extensions.write().await;
if write_lock_guard
.iter()
.any(|ext| ext.developer == extension.developer && ext.id == extension.id)
{
panic!(
"extension [{}/{}] already installed",
extension
.developer
.as_ref()
.expect("just checked it is Some"),
extension.id
);
}
write_lock_guard.push(extension);
}
/// Remove `extension` from the **in-memory** extension list.
pub(crate) async fn remove_extension(&self, developer: &str, extension_id: &str) -> Extension {
let mut write_lock_guard = self.inner.extensions.write().await;
let Some(index) = write_lock_guard
let Some(index) = write_lock
.iter()
.position(|ext| ext.developer.as_deref() == Some(developer) && ext.id == extension_id)
else {
panic!(
"extension [{}/{}] not installed, but we are trying to remove it",
return Err(format!(
"The extension we are trying to uninstall [{}/{}] does not exist",
developer, extension_id
);
));
};
let deleted_extension = write_lock.remove(index);
let extension_dir = {
let mut path = get_third_party_extension_directory(&tauri_app_handle);
path.push(developer);
path.push(extension_id);
path
};
write_lock_guard.remove(index)
if let Err(e) = tokio::fs::remove_dir_all(extension_dir.as_path()).await {
let error_kind = e.kind();
if error_kind == ErrorKind::NotFound {
// We accept this error because we do want it to not exist. But
// since it is not a state we expect, throw a warning.
log::warn!(
"trying to uninstalling extension [developer {} id {}], but its directory does not exist",
developer,
extension_id
);
} else {
return Err(format!(
"failed to uninstall extension [developer {} id {}] due to error {}",
developer, extension_id, e
));
}
}
// Unregister the extension hotkey, if set.
//
// Unregistering hotkey is the only thing that we will do when we disable
// an extension, so we directly use this function here even though "disabling"
// the extension that one is trying to uninstall does not make too much sense.
Self::_disable_extension(&tauri_app_handle, &deleted_extension).await?;
Ok(())
}
/// Take a point-in-time snapshot at the extension list and return it.
pub(crate) async fn extensions_snapshot(&self) -> Vec<Extension> {
self.inner.extensions.read().await.clone()
}
}
pub(super) static THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE: OnceLock<ThirdPartyExtensionsSearchSource> =
pub(crate) static THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE: OnceLock<ThirdPartyExtensionsSearchSource> =
OnceLock::new();
#[derive(Debug)]
@@ -897,37 +923,11 @@ pub(crate) async fn uninstall_extension(
developer: String,
extension_id: String,
) -> Result<(), String> {
let extension_dir = {
let mut path = get_third_party_extension_directory(&tauri_app_handle);
path.push(developer.as_str());
path.push(extension_id.as_str());
path
};
if !extension_dir.try_exists().map_err(|e| e.to_string())? {
panic!(
"we are uninstalling extension [{}/{}], but there is no such extension files on disk",
developer, extension_id
)
}
tokio::fs::remove_dir_all(extension_dir.as_path())
.await
.map_err(|e| e.to_string())?;
let extension = THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE
THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE
.get()
.unwrap()
.remove_extension(&developer, &extension_id)
.await;
// Unregister the extension hotkey, if set.
//
// Unregistering hotkey is the only thing that we will do when we disable
// an extension, so we directly use this function here even though "disabling"
// the extension that one is trying to uninstall does not make too much sense.
ThirdPartyExtensionsSearchSource::_disable_extension(&tauri_app_handle, &extension).await?;
Ok(())
.expect("global third party search source not set")
.uninstall_extension(&tauri_app_handle, &developer, &extension_id)
.await
}
#[cfg(test)]

View File

@@ -10,14 +10,12 @@ mod shortcut;
mod util;
use crate::common::register::SearchSourceRegistry;
// use crate::common::traits::SearchSource;
use crate::common::{CHECK_WINDOW_LABEL, MAIN_WINDOW_LABEL, SETTINGS_WINDOW_LABEL};
use crate::server::servers::{load_or_insert_default_server, load_servers_token};
use autostart::{change_autostart, ensure_autostart_state_consistent};
use autostart::change_autostart;
use lazy_static::lazy_static;
use std::sync::Mutex;
use std::sync::OnceLock;
use tauri::async_runtime::block_on;
use tauri::plugin::TauriPlugin;
use tauri::{AppHandle, Emitter, Manager, PhysicalPosition, WebviewWindow, WindowEvent};
use tauri_plugin_autostart::MacosLauncher;
@@ -70,10 +68,8 @@ pub fn run() {
#[cfg(desktop)]
{
app_builder = app_builder.plugin(tauri_plugin_single_instance::init(|_app, argv, _cwd| {
log::debug!("a new app instance was opened with {argv:?} and the deep link event was already triggered");
// when defining deep link schemes at runtime, you must also check `argv` here
}));
app_builder =
app_builder.plugin(tauri_plugin_single_instance::init(|_app, _argv, _cwd| {}));
}
app_builder = app_builder
@@ -164,6 +160,7 @@ pub fn run() {
extension::unregister_extension_hotkey,
extension::is_extension_enabled,
extension::third_party::install::store::search_extension,
extension::third_party::install::store::extension_detail,
extension::third_party::install::store::install_extension_from_store,
extension::third_party::install::local_extension::install_local_extension,
extension::third_party::uninstall_extension,
@@ -177,17 +174,10 @@ pub fn run() {
extension::built_in::file_search::config::set_file_system_config,
server::synthesize::synthesize,
util::file::get_file_icon,
setup::backend_setup,
util::app_lang::update_app_lang,
#[cfg(target_os = "macos")]
setup::toggle_move_to_active_space_attribute,
])
.setup(|app| {
let app_handle = app.handle().clone();
GLOBAL_TAURI_APP_HANDLE
.set(app_handle.clone())
.expect("global tauri AppHandle already initialized");
log::trace!("global Tauri AppHandle set");
#[cfg(target_os = "macos")]
{
log::trace!("hiding Dock icon on macOS");
@@ -195,67 +185,21 @@ pub fn run() {
log::trace!("Dock icon should be hidden now");
}
let registry = SearchSourceRegistry::default();
app.manage(registry); // Store registry in Tauri's app state
// This has to be called before initializing extensions as doing that
// requires access to the shortcut store, which will be set by this
// function.
shortcut::enable_shortcut(app);
block_on(async {
init(app.handle()).await;
// We want all the extensions here, so no filter condition specified.
match extension::list_extensions(app_handle.clone(), None, None, false).await {
Ok(extensions) => {
// Initializing extension relies on SearchSourceRegistry, so this should
// be executed after `app.manage(registry)`
if let Err(e) =
extension::init_extensions(app_handle.clone(), extensions).await
{
log::error!("initializing extensions failed with error [{}]", e);
}
}
Err(e) => {
log::error!("listing extensions failed with error [{}]", e);
}
}
});
ensure_autostart_state_consistent(app)?;
// app.listen("theme-changed", move |event| {
// if let Ok(payload) = serde_json::from_str::<ThemeChangedPayload>(event.payload()) {
// // switch_tray_icon(app.app_handle(), payload.is_dark_mode);
// log::debug!("Theme changed: is_dark_mode = {}", payload.is_dark_mode);
// }
// });
#[cfg(desktop)]
{
#[cfg(any(windows, target_os = "linux"))]
{
app.deep_link().register("coco")?;
use tauri_plugin_deep_link::DeepLinkExt;
app.deep_link().register_all()?;
}
}
// app.deep_link().on_open_url(|event| {
// dbg!(event.urls());
// });
let main_window = app.get_webview_window(MAIN_WINDOW_LABEL).unwrap();
let settings_window = app.get_webview_window(SETTINGS_WINDOW_LABEL).unwrap();
let check_window = app.get_webview_window(CHECK_WINDOW_LABEL).unwrap();
/* ----------- This code must be executed on the main thread and must not be relocated. ----------- */
let app_handle = app.app_handle();
let main_window = app_handle.get_webview_window(MAIN_WINDOW_LABEL).unwrap();
let settings_window = app_handle
.get_webview_window(SETTINGS_WINDOW_LABEL)
.unwrap();
let check_window = app_handle.get_webview_window(CHECK_WINDOW_LABEL).unwrap();
setup::default(
app,
app_handle,
main_window.clone(),
settings_window.clone(),
check_window.clone(),
);
/* ----------- This code must be executed on the main thread and must not be relocated. ----------- */
Ok(())
})

View File

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

View File

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

View File

@@ -1,4 +1,9 @@
use tauri::{App, WebviewWindow};
use crate::GLOBAL_TAURI_APP_HANDLE;
use crate::autostart;
use crate::common::register::SearchSourceRegistry;
use crate::util::app_lang::update_app_lang;
use std::sync::OnceLock;
use tauri::{AppHandle, Manager, WebviewWindow};
#[cfg(target_os = "macos")]
mod mac;
@@ -19,7 +24,7 @@ pub use windows::*;
pub use linux::*;
pub fn default(
app: &mut App,
tauri_app_handle: &AppHandle,
main_window: WebviewWindow,
settings_window: WebviewWindow,
check_window: WebviewWindow,
@@ -29,9 +34,66 @@ pub fn default(
main_window.open_devtools();
platform(
app,
tauri_app_handle,
main_window.clone(),
settings_window.clone(),
check_window.clone(),
);
}
/// Use this variable to track if tauri command `backend_setup()` gets called
/// by the frontend.
pub(super) static BACKEND_SETUP_FUNC_INVOKED: OnceLock<()> = OnceLock::new();
/// This function includes the setup job that has to be coordinated with the
/// frontend, or the App will panic due to races[1]. The way we coordinate is to
/// expose this function as a Tauri command, and let the frontend code invoke
/// it.
///
/// The frontend code should ensure that:
///
/// 1. This command gets called before invoking other commands.
/// 2. This command should only be called once.
///
/// [1]: For instance, Tauri command `list_extensions()` relies on an in-memory
/// extension list that won't be initialized until `init_extensions()` gets
/// called. If the frontend code invokes `list_extensions()` before `init_extension()`
/// gets executed, we get a panic.
#[tauri::command]
#[function_name::named]
pub(crate) async fn backend_setup(tauri_app_handle: AppHandle, app_lang: String) {
if BACKEND_SETUP_FUNC_INVOKED.get().is_some() {
return;
}
GLOBAL_TAURI_APP_HANDLE
.set(tauri_app_handle.clone())
.expect("global tauri AppHandle already initialized");
log::trace!("global Tauri AppHandle set");
let registry = SearchSourceRegistry::default();
tauri_app_handle.manage(registry); // Store registry in Tauri's app state
// This has to be called before initializing extensions as doing that
// requires access to the shortcut store, which will be set by this
// function.
crate::shortcut::enable_shortcut(&tauri_app_handle);
crate::init(&tauri_app_handle).await;
if let Err(err) = crate::extension::init_extensions(&tauri_app_handle).await {
log::error!(
"failed to initialize extension-related stuff, error [{}]",
err
);
}
autostart::ensure_autostart_state_consistent(&tauri_app_handle).unwrap();
update_app_lang(app_lang).await;
// Invoked, now update the state
BACKEND_SETUP_FUNC_INVOKED
.set(())
.unwrap_or_else(|_| panic!("tauri command {}() gets called twice!", function_name!()));
}

View File

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

View File

@@ -1,5 +1,6 @@
use crate::common::MAIN_WINDOW_LABEL;
use crate::{COCO_TAURI_STORE, hide_coco, show_coco};
use tauri::{App, AppHandle, Manager, async_runtime};
use tauri::{AppHandle, Manager, async_runtime};
use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState};
use tauri_plugin_store::{JsonValue, StoreExt};
@@ -16,9 +17,9 @@ const DEFAULT_SHORTCUT: &str = "command+shift+space";
const DEFAULT_SHORTCUT: &str = "ctrl+shift+space";
/// Set up the shortcut upon app start.
pub fn enable_shortcut(app: &App) {
pub fn enable_shortcut(tauri_app_handle: &AppHandle) {
log::trace!("setting up Coco hotkey");
let store = app
let store = tauri_app_handle
.store(COCO_TAURI_STORE)
.expect("creating a store should not fail");
@@ -33,7 +34,7 @@ pub fn enable_shortcut(app: &App) {
let stored_shortcut = stored_shortcut_str
.parse::<Shortcut>()
.expect("stored shortcut string should be valid");
_register_shortcut_upon_start(app, stored_shortcut);
_register_shortcut_upon_start(tauri_app_handle, stored_shortcut);
} else {
store.set(
COCO_GLOBAL_SHORTCUT,
@@ -42,7 +43,7 @@ pub fn enable_shortcut(app: &App) {
let default_shortcut = DEFAULT_SHORTCUT
.parse::<Shortcut>()
.expect("default shortcut should never be invalid");
_register_shortcut_upon_start(app, default_shortcut);
_register_shortcut_upon_start(tauri_app_handle, default_shortcut);
}
log::trace!("Coco hotkey has been set");
}
@@ -118,12 +119,9 @@ fn _register_shortcut(app: &AppHandle, shortcut: Shortcut) {
.unwrap();
}
use crate::common::MAIN_WINDOW_LABEL;
/// Helper function to register a shortcut, used to set up the shortcut up App's first start.
fn _register_shortcut_upon_start(app: &App, shortcut: Shortcut) {
let handler = app.app_handle();
handler
fn _register_shortcut_upon_start(tauri_app_handle: &AppHandle, shortcut: Shortcut) {
tauri_app_handle
.plugin(
tauri_plugin_global_shortcut::Builder::new()
.with_handler(move |app, scut, event| {
@@ -147,7 +145,10 @@ fn _register_shortcut_upon_start(app: &App, shortcut: Shortcut) {
.build(),
)
.unwrap();
app.global_shortcut().register(shortcut).unwrap();
tauri_app_handle
.global_shortcut()
.register(shortcut)
.unwrap();
}
/// Helper function to get the stored global shortcut, as a string.

View File

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

View File

@@ -1,6 +1,7 @@
pub(crate) mod app_lang;
pub(crate) mod file;
pub(crate) mod platform;
pub(crate) mod system_lang;
pub(crate) mod updater;
use std::{path::Path, process::Command};

View File

@@ -0,0 +1,13 @@
use sys_locale::get_locale;
/// Helper function to get the system language.
///
/// We cannot return `enum Lang` here because Coco has limited language support
/// but the OS supports many more languages.
pub(crate) fn get_system_lang() -> String {
// fall back to English (general) when we cannot get the locale
//
// We replace '-' with '_' in applications-rs, to make the locales match,
// we need to do this here as well.
get_locale().unwrap_or("en".into()).replace('-', "_")
}

View File

@@ -15,7 +15,7 @@
{
"label": "main",
"title": "Coco AI",
"url": "/ui",
"url": "index.html/#/ui",
"height": 590,
"width": 680,
"decorations": false,
@@ -39,7 +39,7 @@
{
"label": "settings",
"title": "Coco AI Settings",
"url": "/ui/settings",
"url": "index.html/#/ui/settings",
"width": 1000,
"minWidth": 1000,
"height": 700,
@@ -59,7 +59,7 @@
{
"label": "check",
"title": "Coco AI Update",
"url": "/ui/check",
"url": "index.html/#/ui/check",
"width": 340,
"minWidth": 340,
"height": 260,
@@ -129,7 +129,6 @@
"shell": {},
"globalShortcut": {},
"deep-link": {
"schema": "coco",
"mobile": [
{
"host": "app.infini.cloud",

View File

@@ -86,11 +86,6 @@ export const Get = <T>(
} else {
res = result?.data as FcResponse<T>;
}
// web component log
infoLog({
username: "@/api/axiosRequest.ts",
logName: url,
})(res);
resolve([null, res as FcResponse<T>]);
})

View File

@@ -16,49 +16,10 @@ import {
} from "@/types/commands";
import { useAppStore } from "@/stores/appStore";
import { useAuthStore } from "@/stores/authStore";
import { useConnectStore } from "@/stores/connectStore";
import { SETTINGS_WINDOW_LABEL } from "@/constants";
import platformAdapter from "@/utils/platformAdapter";
export async function getCurrentWindowService() {
const currentService = useConnectStore.getState().currentService;
const cloudSelectService = useConnectStore.getState().cloudSelectService;
const windowLabel = await platformAdapter.getCurrentWindowLabel();
return windowLabel === SETTINGS_WINDOW_LABEL
? cloudSelectService
: currentService;
}
export async function setCurrentWindowService(service: any) {
const windowLabel = await platformAdapter.getCurrentWindowLabel();
const { setCurrentService, setCloudSelectService } =
useConnectStore.getState();
return windowLabel === SETTINGS_WINDOW_LABEL
? setCloudSelectService(service)
: setCurrentService(service);
}
export async function handleLogout(serverId?: string) {
const setIsCurrentLogin = useAuthStore.getState().setIsCurrentLogin;
const { serverList, setServerList } = useConnectStore.getState();
const service = await getCurrentWindowService();
const id = serverId || service?.id;
if (!id) return;
// Update the status first
setIsCurrentLogin(false);
if (service?.id === id) {
await setCurrentWindowService({ ...service, profile: null });
}
const updatedServerList = serverList.map((server) =>
server.id === id ? { ...server, profile: null } : server
);
setServerList(updatedServerList);
}
import {
getCurrentWindowService,
handleLogout,
} from "@/commands/windowService";
// Endpoints that don't require authentication
const WHITELIST_SERVERS = [

View File

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

View File

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

View File

@@ -390,7 +390,7 @@ const ChatAI = memo(
assistantIDs={assistantIDs}
/>
{isCurrentLogin ? (
{isCurrentLogin || !isTauri ? (
<>
<ChatContent
activeChat={activeChat}

View File

@@ -7,12 +7,12 @@ import PinIcon from "@/icons/Pin";
import WindowsFullIcon from "@/icons/WindowsFull";
import { useAppStore } from "@/stores/appStore";
import type { Chat } from "@/types/chat";
import platformAdapter from "@/utils/platformAdapter";
import VisibleKey from "../Common/VisibleKey";
import { useShortcutsStore } from "@/stores/shortcutsStore";
import { HISTORY_PANEL_ID } from "@/constants";
import { AssistantList } from "./AssistantList";
import { ServerList } from "./ServerList";
import { useTogglePin } from "@/hooks/useTogglePin";
interface ChatHeaderProps {
clearChat: () => void;
@@ -35,12 +35,22 @@ export function ChatHeader({
showChatHistory = true,
assistantIDs,
}: ChatHeaderProps) {
const { isTauri } = useAppStore();
const { isPinned, togglePin } = useTogglePin();
const { isPinned, setIsPinned, isTauri } = useAppStore();
const { historicalRecords, newSession, fixedWindow, external } =
useShortcutsStore();
const togglePin = async () => {
try {
const newPinned = !isPinned;
await platformAdapter.setAlwaysOnTop(newPinned);
setIsPinned(newPinned);
} catch (err) {
console.error("Failed to toggle window pin state:", err);
setIsPinned(isPinned);
}
};
return (
<header
className="flex items-center justify-between py-2 px-3 select-none"

View File

@@ -18,7 +18,7 @@ import StatusIndicator from "@/components/Cloud/StatusIndicator";
import { useAuthStore } from "@/stores/authStore";
import { useSearchStore } from "@/stores/searchStore";
import { useServers } from "@/hooks/useServers";
import { getCurrentWindowService, setCurrentWindowService } from "@/commands";
import { getCurrentWindowService, setCurrentWindowService } from "@/commands/windowService";
interface ServerListProps {
clearChat: () => void;

View File

@@ -68,11 +68,12 @@ export default function Cloud() {
}, [serverList, errors, cloudSelectService]);
const refreshClick = useCallback(
async (id: string) => {
async (id: string, callback?: () => void) => {
setRefreshLoading(true);
await platformAdapter.commands("refresh_coco_server_info", id);
await refreshServerList();
setRefreshLoading(false);
callback && callback();
},
[refreshServerList]
);

View File

@@ -2,11 +2,6 @@ import { FC, memo, useCallback, useEffect, useState } from "react";
import { Copy } from "lucide-react";
import { useTranslation } from "react-i18next";
import { v4 as uuidv4 } from "uuid";
import {
getCurrent as getCurrentDeepLinkUrls,
onOpenUrl,
} from "@tauri-apps/plugin-deep-link";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { UserProfile } from "./UserProfile";
import { OpenURLWithBrowser } from "@/utils";
@@ -18,19 +13,21 @@ import { useServers } from "@/hooks/useServers";
interface ServiceAuthProps {
setRefreshLoading: (loading: boolean) => void;
refreshClick: (id: string) => void;
refreshClick: (id: string, callback?: () => void) => void;
}
const ServiceAuth = memo(
({ setRefreshLoading, refreshClick }: ServiceAuthProps) => {
const { t } = useTranslation();
const language = useAppStore((state) => state.language);
const addError = useAppStore((state) => state.addError);
const ssoRequestID = useAppStore((state) => state.ssoRequestID);
const setSSORequestID = useAppStore((state) => state.setSSORequestID);
const addError = useAppStore((state) => state.addError);
const cloudSelectService = useConnectStore((state) => state.cloudSelectService);
const cloudSelectService = useConnectStore(
(state) => state.cloudSelectService
);
const { logoutServer } = useServers();
@@ -64,100 +61,25 @@ const ServiceAuth = memo(
[logoutServer]
);
const handleOAuthCallback = useCallback(
async (code: string | null, serverId: string | null) => {
if (!code || !serverId) {
addError("No authorization code received");
return;
}
try {
console.log("Handling OAuth callback:", { code, serverId });
await platformAdapter.commands("handle_sso_callback", {
serverId: serverId, // Make sure 'server_id' is the correct argument
requestId: ssoRequestID, // Make sure 'request_id' is the correct argument
code: code,
});
if (serverId != null) {
refreshClick(serverId);
}
getCurrentWindow().setFocus();
} catch (e) {
console.error("Sign in failed:", e);
} finally {
setLoading(false);
}
},
[ssoRequestID]
);
const handleUrl = (url: string) => {
try {
const urlObject = new URL(url.trim());
console.log("handle urlObject:", urlObject);
// pass request_id and check with local, if the request_id are same, then continue
const reqId = urlObject.searchParams.get("request_id");
const code = urlObject.searchParams.get("code");
if (reqId != ssoRequestID) {
console.log("Request ID not matched, skip");
addError("Request ID not matched, skip");
return;
}
const serverId = cloudSelectService?.id;
handleOAuthCallback(code, serverId);
} catch (err) {
console.error("Failed to parse URL:", err);
addError("Invalid URL format: " + err);
}
};
// Fetch the initial deep link intent
// handle oauth success event
useEffect(() => {
// Function to handle pasted URL
const handlePaste = (event: any) => {
const pastedText = event.clipboardData.getData("text").trim();
console.log("handle paste text:", pastedText);
if (isValidCallbackUrl(pastedText)) {
// Handle the URL as if it's a deep link
console.log("handle callback on paste:", pastedText);
handleUrl(pastedText);
}
};
// Function to check if the pasted URL is valid for our deep link scheme
const isValidCallbackUrl = (url: string) => {
return url && url.startsWith("coco://oauth_callback");
};
// Adding event listener for paste events
document.addEventListener("paste", handlePaste);
getCurrentDeepLinkUrls()
.then((urls) => {
console.log("URLs:", urls);
if (urls && urls.length > 0) {
if (isValidCallbackUrl(urls[0].trim())) {
handleUrl(urls[0]);
}
const unlistenOAuth = platformAdapter.listenEvent(
"oauth_success",
(event) => {
const { serverId } = event.payload;
if (serverId) {
refreshClick(serverId, () => {
setLoading(false);
});
addError(language === "zh" ? "登录成功" : "Login Success", "info");
}
})
.catch((err) => {
console.error("Failed to get initial URLs:", err);
addError("Failed to get initial URLs: " + err);
});
const unlisten = onOpenUrl((urls) => handleUrl(urls[0]));
}
);
return () => {
unlisten.then((fn) => fn());
document.removeEventListener("paste", handlePaste);
unlistenOAuth.then((fn) => fn());
};
}, [ssoRequestID]);
}, [refreshClick]);
useEffect(() => {
setLoading(false);
@@ -214,7 +136,9 @@ const ServiceAuth = memo(
<button
className="text-xs text-[#0096FB] dark:text-blue-400 block"
onClick={() =>
OpenURLWithBrowser(cloudSelectService?.provider?.privacy_policy)
OpenURLWithBrowser(
cloudSelectService?.provider?.privacy_policy
)
}
>
{t("cloud.privacyPolicy")}

View File

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

View File

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

View File

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

View File

@@ -21,7 +21,6 @@ import { isLinux, isWin } from "@/utils/platform";
import { appReducer, initialAppState } from "@/reducers/appReducer";
import { useWindowEvents } from "@/hooks/useWindowEvents";
import { useAppStore } from "@/stores/appStore";
import { useAuthStore } from "@/stores/authStore";
import platformAdapter from "@/utils/platformAdapter";
import { useStartupStore } from "@/stores/startupStore";
import { useThemeStore } from "@/stores/themeStore";
@@ -108,10 +107,6 @@ function SearchChat({
useWindowEvents();
const initializeListeners_auth = useAuthStore((state) => {
return state.initializeListeners;
});
const setTheme = useThemeStore((state) => state.setTheme);
const setIsDark = useThemeStore((state) => state.setIsDark);
@@ -128,7 +123,6 @@ function SearchChat({
useEffect(() => {
const init = async () => {
await initializeListeners_auth();
if (isTauri) {
await platformAdapter.commands("get_app_search_source");
}

View File

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

View File

@@ -205,9 +205,15 @@ export const Extensions = () => {
const errorMessage = String(error);
if (errorMessage === "already imported") {
addError(t("settings.extensions.hints.extensionAlreadyImported"));
addError(
t(
"settings.extensions.hints.extensionAlreadyImported"
)
);
} else if (errorMessage === "incompatible") {
addError(t("settings.extensions.hints.incompatibleExtension"));
addError(
t("settings.extensions.hints.incompatibleExtension")
);
} else {
addError(t("settings.extensions.hints.importFailed"));
}

View File

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

View File

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

View File

@@ -9,3 +9,7 @@ export const DEFAULT_COCO_SERVER_ID = "default_coco_server";
export const MAIN_WINDOW_LABEL = "main";
export const SETTINGS_WINDOW_LABEL = "settings";
export const CHECK_WINDOW_LABEL = "check";
export const CHAT_WINDOW_LABEL = "chat";

View File

@@ -524,7 +524,7 @@ export function useChatActions(
skipTaskbar: false,
decorations: true,
closable: true,
url: "/ui/chat",
url: "index.html/#/ui/chat",
});
}
},

View File

@@ -0,0 +1,183 @@
import { useCallback, useEffect } from "react";
import { invoke } from "@tauri-apps/api/core";
import {
getCurrent as getCurrentDeepLinkUrls,
onOpenUrl,
} from "@tauri-apps/plugin-deep-link";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { useAppStore } from "@/stores/appStore";
import { useConnectStore } from "@/stores/connectStore";
import platformAdapter from "@/utils/platformAdapter";
import { useTranslation } from "react-i18next";
import { MAIN_WINDOW_LABEL } from "@/constants";
import { useAsyncEffect, useEventListener } from "ahooks";
export interface DeepLinkHandler {
pattern: string;
handler: (url: URL) => Promise<void> | void;
}
export function useDeepLinkManager() {
const addError = useAppStore((state) => state.addError);
const ssoRequestID = useAppStore((state) => state.ssoRequestID);
const cloudSelectService = useConnectStore(
(state) => state.cloudSelectService
);
const { t } = useTranslation();
// handle oauth callback
const handleOAuthCallback = useCallback(
async (url: URL) => {
try {
const reqId = url.searchParams.get("request_id");
const code = url.searchParams.get("code");
if (reqId !== ssoRequestID) {
console.log("Request ID not matched, skip");
addError("Request ID not matched, skip");
return;
}
const serverId = cloudSelectService?.id;
if (!code || !serverId) {
addError("No authorization code received");
return;
}
console.log("Handling OAuth callback:", { code, serverId });
await platformAdapter.commands("handle_sso_callback", {
serverId: serverId,
requestId: ssoRequestID,
code: code,
});
// trigger oauth success event
platformAdapter.emitEvent("oauth_success", { serverId });
getCurrentWindow().setFocus();
} catch (err) {
console.error("Failed to parse OAuth callback URL:", err);
addError("Invalid OAuth callback URL format: " + err);
}
},
[ssoRequestID, cloudSelectService, addError]
);
// handle install extension from store
const handleInstallExtension = useCallback(async (url: URL) => {
const extensionId = url.searchParams.get("id");
if (!extensionId) {
return console.warn(
'received an invalid install_extension_from_store deeplink, missing argument "id"'
);
}
try {
await platformAdapter.showWindow();
await invoke("install_extension_from_store", { id: extensionId });
// trigger extension install success event
platformAdapter.emitEvent("extension_install_success", { extensionId });
addError(t("deepLink.extensionInstallSuccessfully"), "info");
console.log("Extension installed successfully:", extensionId);
} catch (error) {
addError(String(error));
}
}, []);
// handle deep link
const handlers: DeepLinkHandler[] = [
{
pattern: "oauth_callback",
handler: handleOAuthCallback,
},
{
pattern: "install_extension_from_store",
handler: async (url) => {
const windowLabel = await platformAdapter.getCurrentWindowLabel();
if (windowLabel !== MAIN_WINDOW_LABEL) return;
handleInstallExtension(url);
},
},
];
// handle deep link
const handleUrl = useCallback(
(url: string) => {
console.debug("handling deeplink URL", url);
try {
const urlObject = new URL(url.trim());
const deeplinkIdentifier = urlObject.hostname;
// find handler by pattern
const handler = handlers.find((h) => h.pattern === deeplinkIdentifier);
if (handler) {
handler.handler(urlObject);
} else {
console.error("Unknown deep link:", url);
addError("Unknown deep link: " + url);
}
} catch (err) {
console.error("Failed to parse URL:", err);
addError("Invalid URL format: " + err);
}
},
[handlers]
);
// handle paste text
const handlePaste = useCallback(
(event: ClipboardEvent) => {
const pastedText = event.clipboardData?.getData("text")?.trim();
console.log("handle paste text:", pastedText);
// coco://oauth_callback
if (pastedText && pastedText.startsWith("coco://oauth_callback")) {
console.log("handle deeplink on paste:", pastedText);
handleUrl(pastedText);
}
},
[handleUrl]
);
// get initial deep link
useAsyncEffect(async () => {
try {
const urls = await getCurrentDeepLinkUrls();
console.log("Initial DeepLinkUrls:", urls);
if (urls && urls.length > 0) {
handleUrl(urls[0]);
}
} catch (error) {
addError("Failed to get initial URLs: " + error);
}
}, []);
// handle deep link on paste
useEffect(() => {
// handle new deep link
const unlisten = onOpenUrl((urls) => {
console.log("onOpenUrl urls", urls);
handleUrl(urls[0]);
});
return () => {
unlisten.then((fn) => fn());
};
}, []);
// add paste event listener
useEventListener("paste", handlePaste);
return {
handleUrl,
};
}

View File

@@ -7,7 +7,7 @@ import {
getCurrentWindowService,
setCurrentWindowService,
handleLogout,
} from "@/commands/servers";
} from "@/commands/windowService";
export const useServers = () => {
const setServerList = useConnectStore((state) => state.setServerList);

View File

@@ -1,33 +0,0 @@
import { useCallback } from "react";
import { useAppStore } from "@/stores/appStore";
import platformAdapter from "@/utils/platformAdapter";
interface UseTogglePinOptions {
onPinChange?: (isPinned: boolean) => void;
}
export const useTogglePin = (options?: UseTogglePinOptions) => {
const { isPinned, setIsPinned } = useAppStore();
const togglePin = useCallback(async () => {
try {
const newPinned = !isPinned;
if (options?.onPinChange) {
options.onPinChange(newPinned);
}
await platformAdapter.setAlwaysOnTop(newPinned);
await platformAdapter.toggleMoveToActiveSpaceAttribute();
setIsPinned(newPinned);
} catch (err) {
console.error("Failed to toggle window pin state:", err);
}
}, [isPinned, setIsPinned, options?.onPinChange]);
return {
isPinned,
togglePin,
};
};

View File

@@ -209,7 +209,9 @@
"importSuccess": "Extension imported successfully.",
"importFailed": "No valid extension found in the selected folder. Please check the folder structure.",
"extensionAlreadyImported": "Extension already imported. Please remove it first.",
"incompatibleExtension": "This extension is incompatible with your OS."
"incompatibleExtension": "This extension is incompatible with your OS.",
"uninstall": "Uninstall",
"uninstallSuccess": "Uninstalled successfully"
},
"application": {
"title": "Applications",
@@ -405,8 +407,8 @@
"error": "Request error. Please try again later.",
"logo_alt": "Login Logo",
"welcome": "Welcome to Coco AI",
"connect_tip": "To start a conversation, please connect to the service and log in to your account.",
"connect": "Connect"
"connect_tip": "To start a conversation, please log in to your account.",
"connect": "Login"
},
"input": {
"stopMessage": "Stop message",
@@ -571,5 +573,8 @@
"cancel": "Cancel",
"delete": "Delete"
}
},
"deepLink": {
"extensionInstallSuccessfully": "Extension installed successfully."
}
}

View File

@@ -209,7 +209,9 @@
"importSuccess": "插件导入成功。",
"importFailed": "未在该目录中找到有效的插件,请检查目录结构是否正确。",
"extensionAlreadyImported": "插件已存在,无法重复导入。请先将其删除后再尝试。",
"incompatibleExtension": "此插件与当前操作系统不兼容。"
"incompatibleExtension": "此插件与当前操作系统不兼容。",
"uninstall": "卸载",
"uninstallSuccess": "卸载成功"
},
"application": {
"title": "应用程序",
@@ -405,8 +407,8 @@
"error": "请求错误,请稍后再试。",
"logo_alt": "登录图标",
"welcome": "欢迎使用 Coco AI",
"connect_tip": "要开始对话,请连接服务并登录您的账户。",
"connect": "连接"
"connect_tip": "要开始对话,请登录您的账户。",
"connect": "登录"
},
"input": {
"stopMessage": "停止生成",
@@ -570,5 +572,8 @@
"cancel": "取消",
"delete": "删除"
}
},
"deepLink": {
"extensionInstallSuccessfully": "扩展安装成功。"
}
}

View File

@@ -1,18 +1,9 @@
import { useEffect } from "react";
import { useUpdateStore } from "@/stores/updateStore";
import UpdateApp from "@/components/UpdateApp";
import { useSyncStore } from "@/hooks/useSyncStore";
const CheckApp = () => {
const { setVisible } = useUpdateStore();
useSyncStore();
useEffect(() => {
setVisible(true);
}, []);
return <UpdateApp isCheckPage />;
};

View File

@@ -1,4 +1,4 @@
import { createBrowserRouter } from "react-router-dom";
import { createHashRouter } from "react-router-dom";
import Layout from "./layout";
import ErrorPage from "@/pages/error/index";
@@ -16,7 +16,7 @@ const routerOptions = {
},
} as const;
export const router = createBrowserRouter(
export const router = createHashRouter(
[
{
path: "/",

View File

@@ -1,147 +1,47 @@
import { useEffect, useState } from "react";
import { Outlet, useLocation } from "react-router-dom";
import { useTranslation } from "react-i18next";
import {
useAsyncEffect,
useEventListener,
useMount,
useTextSelection,
} from "ahooks";
import { isArray, isString } from "lodash-es";
import { useMount, useSessionStorageState } from "ahooks";
import { useEffect } from "react";
import { invoke } from "@tauri-apps/api/core";
import LayoutOutlet from "./outlet";
import { useAppStore } from "@/stores/appStore";
import useEscape from "@/hooks/useEscape";
import useSettingsWindow from "@/hooks/useSettingsWindow";
import { useThemeStore } from "@/stores/themeStore";
import platformAdapter from "@/utils/platformAdapter";
import { AppTheme } from "@/types/index";
import ErrorNotification from "@/components/Common/ErrorNotification";
import { useModifierKeyPress } from "@/hooks/useModifierKeyPress";
import { useIconfontScript } from "@/hooks/useScript";
import { Extension } from "@/components/Settings/Extensions";
import { useExtensionsStore } from "@/stores/extensionsStore";
import { useServers } from "@/hooks/useServers";
export default function Layout() {
const location = useLocation();
import { CHAT_WINDOW_LABEL, MAIN_WINDOW_LABEL } from "@/constants";
const Layout = () => {
const { language } = useAppStore();
const { i18n } = useTranslation();
const { activeTheme, isDark, setIsDark, setTheme } = useThemeStore();
// init servers isTauri
useServers();
const [langUpdated, setLangUpdated] = useState(false);
useAsyncEffect(async () => {
i18n.changeLanguage(language);
await platformAdapter.invokeBackend("update_app_lang", {
lang: language,
});
setLangUpdated(true);
}, [language]);
function updateBodyClass(path: string) {
const body = document.body;
body.classList.remove("input-body");
if (path === "/ui") {
body.classList.add("input-body");
}
}
const [ready, setReady] = useSessionStorageState("rust_ready", {
defaultValue: false,
});
useMount(async () => {
await platformAdapter.setShadow(true);
const label = await platformAdapter.getCurrentWindowLabel();
const unlistenTheme = await platformAdapter.listenThemeChanged(
(theme: AppTheme) => {
setTheme(theme);
setIsDark(theme === "dark");
}
);
if (label === CHAT_WINDOW_LABEL) {
setReady(true);
}
platformAdapter.onThemeChanged(({ payload }) => {
if (activeTheme !== "auto") return;
if (ready || label !== MAIN_WINDOW_LABEL) return;
setIsDark(payload === "dark");
await invoke("backend_setup", {
appLang: language,
});
setReady(true);
platformAdapter.emitEvent("rust_ready");
});
useEffect(() => {
const unlisten = platformAdapter.listenEvent("rust_ready", () => {
setReady(true);
});
return () => {
unlistenTheme();
unlisten.then((fn) => fn());
};
});
}, []);
useAsyncEffect(async () => {
let nextTheme: any = activeTheme === "auto" ? null : activeTheme;
return ready && <LayoutOutlet />;
};
await platformAdapter.setWindowTheme(nextTheme);
nextTheme = nextTheme ?? (await platformAdapter.getWindowTheme());
setIsDark(nextTheme === "dark");
}, [activeTheme]);
useEffect(() => {
const theme = isDark ? "dark" : "light";
const root = window.document.documentElement;
root.className = theme;
root.dataset.theme = theme;
}, [isDark]);
useEffect(() => {
updateBodyClass(location.pathname);
}, [location.pathname]);
useEscape();
useSettingsWindow();
const { text: selectionText } = useTextSelection();
// Disable right-click for production environment
useEventListener("contextmenu", (event) => {
if (import.meta.env.DEV || selectionText) return;
event.preventDefault();
});
useModifierKeyPress();
useEventListener("unhandledrejection", ({ reason }) => {
const message = isString(reason) ? reason : JSON.stringify(reason);
platformAdapter.error(message);
});
useIconfontScript();
const setDisabledExtensions = useExtensionsStore((state) => {
return state.setDisabledExtensions;
});
useMount(async () => {
const result = await platformAdapter.invokeBackend<Extension[]>(
"list_extensions",
{
listEnabled: false,
}
);
if (!isArray(result)) return;
const disabledExtensions = result.filter((item) => !item.enabled);
setDisabledExtensions(disabledExtensions.map((item) => item.id));
});
return (
<>
{langUpdated && <Outlet />}
<ErrorNotification />
</>
);
}
export default Layout;

142
src/routes/outlet.tsx Normal file
View File

@@ -0,0 +1,142 @@
import { useEffect } from "react";
import { Outlet, useLocation } from "react-router-dom";
import { useTranslation } from "react-i18next";
import {
useAsyncEffect,
useEventListener,
useMount,
useTextSelection,
} from "ahooks";
import { isArray, isString } from "lodash-es";
import { useAppStore } from "@/stores/appStore";
import useEscape from "@/hooks/useEscape";
import useSettingsWindow from "@/hooks/useSettingsWindow";
import { useThemeStore } from "@/stores/themeStore";
import platformAdapter from "@/utils/platformAdapter";
import { AppTheme } from "@/types/index";
import ErrorNotification from "@/components/Common/ErrorNotification";
import { useModifierKeyPress } from "@/hooks/useModifierKeyPress";
import { useIconfontScript } from "@/hooks/useScript";
import { Extension } from "@/components/Settings/Extensions";
import { useExtensionsStore } from "@/stores/extensionsStore";
import { useServers } from "@/hooks/useServers";
import { useDeepLinkManager } from "@/hooks/useDeepLinkManager";
export default function LayoutOutlet() {
const location = useLocation();
const { language } = useAppStore();
const { i18n } = useTranslation();
const { activeTheme, isDark, setIsDark, setTheme } = useThemeStore();
// init servers isTauri
useServers();
// init deep link manager
useDeepLinkManager();
useEffect(() => {
i18n.changeLanguage(language);
}, [language]);
function updateBodyClass(path: string) {
const body = document.body;
body.classList.remove("input-body");
if (path === "/ui") {
body.classList.add("input-body");
}
}
useMount(async () => {
await platformAdapter.setShadow(true);
const unlistenTheme = await platformAdapter.listenThemeChanged(
(theme: AppTheme) => {
setTheme(theme);
setIsDark(theme === "dark");
}
);
platformAdapter.onThemeChanged(({ payload }) => {
if (activeTheme !== "auto") return;
setIsDark(payload === "dark");
});
return () => {
unlistenTheme();
};
});
useAsyncEffect(async () => {
let nextTheme: any = activeTheme === "auto" ? null : activeTheme;
await platformAdapter.setWindowTheme(nextTheme);
nextTheme = nextTheme ?? (await platformAdapter.getWindowTheme());
setIsDark(nextTheme === "dark");
}, [activeTheme]);
useEffect(() => {
const theme = isDark ? "dark" : "light";
const root = window.document.documentElement;
root.className = theme;
root.dataset.theme = theme;
}, [isDark]);
useEffect(() => {
updateBodyClass(location.pathname);
}, [location.pathname]);
useEscape();
useSettingsWindow();
const { text: selectionText } = useTextSelection();
// Disable right-click for production environment
useEventListener("contextmenu", (event) => {
if (import.meta.env.DEV || selectionText) return;
event.preventDefault();
});
useModifierKeyPress();
useEventListener("unhandledrejection", ({ reason }) => {
const message = isString(reason) ? reason : JSON.stringify(reason);
platformAdapter.error(message);
});
useIconfontScript();
const setDisabledExtensions = useExtensionsStore((state) => {
return state.setDisabledExtensions;
});
useMount(async () => {
const result = await platformAdapter.invokeBackend<Extension[]>(
"list_extensions",
{
listEnabled: false,
}
);
if (!isArray(result)) return;
const disabledExtensions = result.filter((item) => !item.enabled);
setDisabledExtensions(disabledExtensions.map((item) => item.id));
});
return (
<>
<Outlet />
<ErrorNotification />
</>
);
}

View File

@@ -1,11 +1,5 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { produce } from "immer";
import platformAdapter from "@/utils/platformAdapter";
const AUTH_CHANGE_EVENT = "auth-changed";
const USERINFO_CHANGE_EVENT = "userInfo-changed";
export type Plan = {
upgraded: boolean;
@@ -19,93 +13,23 @@ export type AuthProp = {
plan?: Plan | null;
};
type AuthMapProp = {
[key: string]: AuthProp;
};
type userInfoMapProp = {
[key: string]: any;
};
export type IAuthStore = {
[x: string]: any;
auth: AuthMapProp | undefined;
userInfo: userInfoMapProp;
setAuth: (auth: AuthProp | undefined, key: string) => void;
resetAuth: (key: string) => void;
isCurrentLogin: boolean;
setIsCurrentLogin: (isCurrentLogin: boolean) => void;
initializeListeners: () => Promise<() => void>;
};
export const useAuthStore = create<IAuthStore>()(
persist(
(set) => ({
auth: undefined,
userInfo: {},
setAuth: async (auth, key) => {
set(
produce((draft) => {
draft.auth[key] = auth;
})
);
await platformAdapter.emitEvent(AUTH_CHANGE_EVENT, {
auth: {
[key]: auth,
},
});
},
resetAuth: async (key: string) => {
set(
produce((draft) => {
draft.auth[key] = undefined;
})
);
await platformAdapter.emitEvent(AUTH_CHANGE_EVENT, {
auth: {
[key]: undefined,
},
});
},
setUserInfo: async (userInfo: any, key: string) => {
set(
produce((draft) => {
draft.userInfo[key] = userInfo;
})
);
await platformAdapter.emitEvent(USERINFO_CHANGE_EVENT, {
userInfo: {
[key]: userInfo,
},
});
},
isCurrentLogin: true,
setIsCurrentLogin: (isCurrentLogin: boolean) => {
set({ isCurrentLogin });
},
initializeListeners: async () => {
await platformAdapter.listenEvent(AUTH_CHANGE_EVENT, (event: any) => {
const { auth } = event.payload;
set({ auth });
});
return platformAdapter.listenEvent(
USERINFO_CHANGE_EVENT,
(event: any) => {
const { userInfo } = event.payload;
set({ userInfo });
}
);
},
}),
{
name: "auth-store",
partialize: (state) => ({
auth: state.auth,
userInfo: state.userInfo,
isCurrentLogin: state.isCurrentLogin,
}),
}
)

View File

@@ -17,12 +17,6 @@ export interface EventPayloads {
"showTooltip-changed": {
showTooltip: boolean;
};
"auth-changed": {
auth: Record<string, unknown>;
};
"userInfo-changed": {
userInfo: Record<string, unknown>;
};
open_settings: string | "";
tab_index: string | "";
login_or_logout: unknown;
@@ -51,6 +45,9 @@ export interface EventPayloads {
"chat-create-error": string;
[key: `synthesize-${string}`]: any;
"check-update": any;
oauth_success: any;
extension_install_success: any;
rust_ready: boolean;
}
// Window operation interface
@@ -59,7 +56,6 @@ export interface WindowOperations {
hideWindow: () => Promise<void>;
showWindow: () => Promise<void>;
setAlwaysOnTop: (isPinned: boolean) => Promise<void>;
toggleMoveToActiveSpaceAttribute: () => Promise<void>;
setShadow(enable: boolean): Promise<void>;
getWebviewWindow: () => Promise<any>;
getWindowByLabel: (label: string) => Promise<{
@@ -76,6 +72,7 @@ export interface WindowOperations {
event: string,
callback: (event: any) => void
) => Promise<() => void>;
getCurrentWindowLabel: () => Promise<string>;
}
// Theme and event related interface

View File

@@ -16,8 +16,6 @@ import { useAppearanceStore } from "@/stores/appearanceStore";
import { copyToClipboard, OpenURLWithBrowser } from ".";
import { useAppStore } from "@/stores/appStore";
import { unrequitable } from "@/utils";
import { toggle_move_to_active_space_attribute } from "@/commands/system";
import { isMac } from "@/utils/platform";
export interface TauriPlatformAdapter extends BasePlatformAdapter {
openFileDialog: (
@@ -25,7 +23,6 @@ export interface TauriPlatformAdapter extends BasePlatformAdapter {
) => Promise<string | string[] | null>;
metadata: typeof metadata;
error: typeof error;
getCurrentWindowLabel: () => Promise<string>;
}
// Create Tauri adapter functions
@@ -42,7 +39,9 @@ export const createTauriAdapter = (): TauriPlatformAdapter => {
async showWindow() {
const window = await windowWrapper.getWebviewWindow();
return window?.show();
window?.show();
window?.unminimize();
return window?.setFocus();
},
async emitEvent(event, payload) {
@@ -82,13 +81,6 @@ export const createTauriAdapter = (): TauriPlatformAdapter => {
return window.setAlwaysOnTop(isPinned);
},
async toggleMoveToActiveSpaceAttribute() {
if (isMac) {
return toggle_move_to_active_space_attribute();
}
return Promise.resolve();
},
async requestScreenRecordingPermission() {
const { requestScreenRecordingPermission } = await import(
"tauri-plugin-macos-permissions-api"

View File

@@ -58,10 +58,6 @@ export const createWebAdapter = (): WebPlatformAdapter => {
console.log("Web mode simulated set always on top", isPinned);
},
async toggleMoveToActiveSpaceAttribute() {
console.log("Web mode simulated toggle move to active space attribute");
},
async checkScreenRecordingPermission() {
console.log("Web mode simulated check screen recording permission");
return false;
@@ -273,5 +269,9 @@ export const createWebAdapter = (): WebPlatformAdapter => {
return res;
},
async getCurrentWindowLabel() {
return "web";
},
};
};