mirror of
https://github.com/infinilabs/coco-app.git
synced 2025-12-23 23:09:25 +01:00
Compare commits
21 Commits
microphone
...
refactor-h
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
534e2dddab | ||
|
|
5dae5d1cc1 | ||
|
|
23372655ca | ||
|
|
f5b33af7f1 | ||
|
|
993da9a8ad | ||
|
|
93f1024230 | ||
|
|
7b5e528060 | ||
|
|
1d5ba3ab07 | ||
|
|
f93c527561 | ||
|
|
6065353ac9 | ||
|
|
783cb73b29 | ||
|
|
ee75f0d119 | ||
|
|
aaac874f2c | ||
|
|
cd9e454991 | ||
|
|
d0fc79238b | ||
|
|
3ed84c2318 | ||
|
|
bd039398ba | ||
|
|
568db6aba0 | ||
|
|
2eb10933e7 | ||
|
|
5c6cf18139 | ||
|
|
01c31d884a |
34
.github/workflows/frontend-ci.yml
vendored
Normal file
34
.github/workflows/frontend-ci.yml
vendored
Normal 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
|
||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -83,5 +83,6 @@
|
||||
"i18n-ally.keystyle": "nested",
|
||||
"editor.tabSize": 2,
|
||||
"editor.insertSpaces": true,
|
||||
"editor.detectIndentation": false
|
||||
"editor.detectIndentation": false,
|
||||
"i18n-ally.displayLanguage": "zh"
|
||||
}
|
||||
@@ -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
84
src-tauri/Cargo.lock
generated
@@ -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",
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -69,6 +69,7 @@
|
||||
"updater:default",
|
||||
"windows-version:default",
|
||||
"log:default",
|
||||
"opener:default"
|
||||
"opener:default",
|
||||
"core:window:allow-unminimize"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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?
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
37
src-tauri/src/extension/third_party/check.rs
vendored
37
src-tauri/src/extension/third_party/check.rs
vendored
@@ -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 */
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(¤t_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(())
|
||||
}
|
||||
|
||||
130
src-tauri/src/extension/third_party/mod.rs
vendored
130
src-tauri/src/extension/third_party/mod.rs
vendored
@@ -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)]
|
||||
|
||||
@@ -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(())
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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!()));
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
)
|
||||
});
|
||||
|
||||
|
||||
@@ -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};
|
||||
|
||||
13
src-tauri/src/util/system_lang.rs
Normal file
13
src-tauri/src/util/system_lang.rs
Normal 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('-', "_")
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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>]);
|
||||
})
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
53
src/commands/windowService.ts
Normal file
53
src/commands/windowService.ts
Normal 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);
|
||||
}
|
||||
@@ -390,7 +390,7 @@ const ChatAI = memo(
|
||||
assistantIDs={assistantIDs}
|
||||
/>
|
||||
|
||||
{isCurrentLogin ? (
|
||||
{isCurrentLogin || !isTauri ? (
|
||||
<>
|
||||
<ChatContent
|
||||
activeChat={activeChat}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]
|
||||
);
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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", "");
|
||||
}, []);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 />;
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -524,7 +524,7 @@ export function useChatActions(
|
||||
skipTaskbar: false,
|
||||
decorations: true,
|
||||
closable: true,
|
||||
url: "/ui/chat",
|
||||
url: "index.html/#/ui/chat",
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
183
src/hooks/useDeepLinkManager.ts
Normal file
183
src/hooks/useDeepLinkManager.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
getCurrentWindowService,
|
||||
setCurrentWindowService,
|
||||
handleLogout,
|
||||
} from "@/commands/servers";
|
||||
} from "@/commands/windowService";
|
||||
|
||||
export const useServers = () => {
|
||||
const setServerList = useConnectStore((state) => state.setServerList);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "扩展安装成功。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 />;
|
||||
};
|
||||
|
||||
|
||||
@@ -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: "/",
|
||||
|
||||
@@ -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
142
src/routes/outlet.tsx
Normal 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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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";
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user