1 Commits

Author SHA1 Message Date
ayang
97d28ed9ba refactor: 迁移到 easy-live2d 并重构相关代码 2025-06-11 22:29:27 +08:00
32 changed files with 376 additions and 1457 deletions

View File

@@ -24,7 +24,7 @@ brew tap ayangweb/BongoCat
2. 安装:
```bash
brew install --no-quarantine bongo-cat
brew install bongo-cat
```
3. 更新:

View File

@@ -1,4 +1,4 @@
name: BongoCat Release
name: Release CI
on:
push:
@@ -88,7 +88,7 @@ jobs:
node-version: 20
cache: pnpm
- name: Install front-end dependencies
- name: Install app dependencies and build web
run: pnpm install --frozen-lockfile
- name: Build the app

View File

@@ -1,20 +0,0 @@
name: Upload Release to UpgradeLink
on:
release:
types: [published]
workflow_dispatch:
jobs:
upgradeLink-upload:
permissions:
contents: write
runs-on: ubuntu-latest
steps:
- name: Send a request to UpgradeLink
uses: toolsetlink/upgradelink-action@v5
with:
source-url: 'https://github.com/ayangweb/BongoCat/releases/latest/download/latest.json'
access-key: ${{ secrets.UPGRADE_LINK_ACCESS_KEY }}
tauri-key: ${{ secrets.UPGRADE_LINK_TAURI_KEY }}
github-token: ${{ secrets.RELEASE_TOKEN }}

View File

@@ -48,8 +48,5 @@
"scss",
"pcss",
"postcss"
],
"typescript.enablePromptUseWorkspaceTsdk": true,
"typescript.tsdk": "./node_modules/typescript/lib"
]
}

64
Cargo.lock generated
View File

@@ -450,11 +450,9 @@ dependencies = [
[[package]]
name = "bongo-cat"
version = "0.6.2"
version = "0.5.0"
dependencies = [
"fs_extra",
"input",
"nix",
"rdev",
"serde",
"serde_json",
@@ -1981,12 +1979,6 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hermit-abi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024"
[[package]]
name = "hermit-abi"
version = "0.5.1"
@@ -2330,36 +2322,6 @@ dependencies = [
"libc",
]
[[package]]
name = "input"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fbdc09524a91f9cacd26f16734ff63d7dc650daffadd2b6f84d17a285bd875a9"
dependencies = [
"bitflags 2.9.1",
"input-sys",
"libc",
"log",
"udev",
]
[[package]]
name = "input-sys"
version = "1.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd4f5b4d1c00331c5245163aacfe5f20be75b564c7112d45893d4ae038119eb0"
[[package]]
name = "io-lifetimes"
version = "1.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eae7b9aee968036d54dce06cebaefd919e4472e753296daccd6d344e3e2df0c2"
dependencies = [
"hermit-abi 0.3.9",
"libc",
"windows-sys 0.48.0",
]
[[package]]
name = "ipnet"
version = "2.11.0"
@@ -2580,16 +2542,6 @@ dependencies = [
"redox_syscall",
]
[[package]]
name = "libudev-sys"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c8469b4a23b962c1396b9b451dda50ef5b283e8dd309d69033475fa9b334324"
dependencies = [
"libc",
"pkg-config",
]
[[package]]
name = "linux-raw-sys"
version = "0.4.15"
@@ -3481,7 +3433,7 @@ checksum = "b53a684391ad002dd6a596ceb6c74fd004fdce75f4be2e3f615068abbea5fd50"
dependencies = [
"cfg-if",
"concurrent-queue",
"hermit-abi 0.5.1",
"hermit-abi",
"pin-project-lite",
"rustix 1.0.7",
"tracing",
@@ -5654,18 +5606,6 @@ version = "1.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f"
[[package]]
name = "udev"
version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af4e37e9ea4401fc841ff54b9ddfc9be1079b1e89434c1a6a865dd68980f7e9f"
dependencies = [
"io-lifetimes",
"libc",
"libudev-sys",
"pkg-config",
]
[[package]]
name = "uds_windows"
version = "1.1.0"

View File

@@ -51,15 +51,15 @@
</p>
</div>
| macOS | Windows | Linux(x11) |
| -------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
| ![macOS](https://i0.hdslb.com/bfs/openplatform/dff276b96d49c5d6c431b74b531aab72191b3d87.png) | ![Windows](https://i0.hdslb.com/bfs/openplatform/a4149b753856ee7f401989da902cf3b5ad35b39e.png) | ![Linux](https://i0.hdslb.com/bfs/openplatform/3b49f961819d3ff63b2b80251c1cc13c27e986b0.png) |
| macOS | Window | Linux(x11) |
| ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
| ![macOS](https://github.com/user-attachments/assets/e932b022-1472-4bbd-87ef-4a8ea374890a) | ![Windows](https://github.com/user-attachments/assets/79a4652e-0d14-412d-a274-4ccdd825d7c6) | ![Linux](https://github.com/user-attachments/assets/fd069c12-d12d-423b-b792-98b5926a7f09) |
## 开发背景
本项目的灵感来源于 [MMmmmoko](https://github.com/MMmmmoko) 大佬开发的 [Bongo-Cat-Mver](https://github.com/MMmmmoko/Bongo-Cat-Mver)。它以独特的猫咪互动功能深受用户喜爱,但仅支持 Windows 平台。作为一名深度 macOS 用户,我特别希望在自己的设备上也能使用这款可爱的猫咪,于是我决定开发一个适配 macOS 的版本。
同时,得益于 [Tauri](https://github.com/tauri-apps/tauri) 强大的跨平台能力,本项目不仅支持 macOS还兼容 Windows 和 Linux让更多的用户都能与这只可爱的猫咪互动
同时,得益于 [Tauri](https://github.com/tauri-apps/tauri) 强大的跨平台能力,本项目不仅支持 macOS还兼容 Windows 和 Linux(x11),让更多的用户都能与这只可爱的猫咪互动!
## 下载
@@ -70,16 +70,12 @@
## 功能介绍
- 适配 macOS、Windows 和 Linux。
- 适配 macOS、Windows 和 Linux(x11)
- 根据据键盘或鼠标操作,同步移动鼠标或敲击键盘。
- 支持导入自定义模型,自由打造专属猫咪形象。
- 完全开源,代码公开透明,绝不收集任何用户数据。
- 支持离线运行,无需联网,保护用户隐私。
## 使用提示
- Linux 下需要用户系统安装 libinput 并加入 `input` 用户组,方可在 X11 和 Wayland 下正常使用。
## 更多模型
你可以在这个仓库中探索、下载更多猫咪模型,或提交你的创作,与大家一起分享:
@@ -90,9 +86,9 @@
<a href="https://qm.qq.com/q/AS3gNv2Vzy">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://i0.hdslb.com/bfs/openplatform/5ad8e4278c525cca6d3b4426c30b6d299d8a9654.png" />
<source media="(prefers-color-scheme: light)" srcset="https://i0.hdslb.com/bfs/openplatform/599680ad67bc9f9f876f76069c2239e9a85bb54d.png" />
<img alt="QQ Group" src="https://i0.hdslb.com/bfs/openplatform/599680ad67bc9f9f876f76069c2239e9a85bb54d.png" height="250" />
<source media="(prefers-color-scheme: dark)" srcset="https://github.com/user-attachments/assets/6428b6ff-0b39-4e16-a750-bad6f6b376e9" />
<source media="(prefers-color-scheme: light)" srcset="https://github.com/user-attachments/assets/e50883e2-0be7-4eea-9879-e725fa1dffd2" />
<img alt="QQ Group" src="https://github.com/user-attachments/assets/e50883e2-0be7-4eea-9879-e725fa1dffd2" height="250" />
</picture>
</a>
@@ -113,7 +109,3 @@
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=ayangweb/BongoCat&type=Date" />
</picture>
</a>
## 致谢
- 特别感谢 [UpgradeLink](https://www.toolsetlink.com/) 提供高效稳定的自动更新服务,让本项目得以持续为用户带来最新版本的优质体验。

View File

@@ -1,7 +1,7 @@
{
"name": "bongo-cat",
"type": "module",
"version": "0.6.2",
"version": "0.5.0",
"private": true,
"author": {
"name": "ayangweb",
@@ -37,17 +37,16 @@
"@vueuse/core": "^13.3.0",
"ant-design-vue": "^4.2.6",
"dayjs": "^1.11.13",
"easy-live2d": "^0.3.2",
"es-toolkit": "^1.38.0",
"is-url": "^1.2.4",
"nanoid": "^5.1.5",
"pinia": "^3.0.3",
"pixi-live2d-display": "^0.4.0",
"pixi.js": "^6.5.10",
"pixi.js": "^8.10.1",
"tauri-plugin-macos-permissions-api": "^2.3.0",
"vue": "^3.5.16",
"vue-markdown-render": "^2.2.1",
"vue-router": "^4.5.1",
"vue3-masonry-css": "^1.0.7"
"vue-router": "^4.5.1"
},
"devDependencies": {
"@antfu/eslint-config": "^4.13.3",

901
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package]
name = "bongo-cat"
version = "0.6.2"
version = "0.5.0"
description = "A Tauri App"
authors = [ "ayangweb" ]
edition = "2024"
@@ -22,6 +22,7 @@ tauri = { workspace = true, features = ["tray-icon", "protocol-asset", "macos-pr
serde = { workspace = true, features = ["derive"] }
serde_json.workspace = true
tauri-plugin-custom-window.workspace = true
rdev = { git = "https://github.com/ayangweb/rdev" }
tauri-plugin-os = "2"
tauri-plugin-process = "2"
tauri-plugin-opener = "2"
@@ -41,12 +42,5 @@ tauri-plugin-global-shortcut = "2"
[target."cfg(target_os = \"macos\")".dependencies]
tauri-nspanel.workspace = true
[target."cfg(not(target_os = \"linux\"))".dependencies]
rdev = { git = "https://github.com/ayangweb/rdev" }
[target."cfg(target_os = \"linux\")".dependencies]
nix = { version = "0.30", features = ["poll"] }
input = "0.9"
[features]
cargo-clippy = []

View File

@@ -1,22 +1,35 @@
use rdev::{Event, EventType, listen};
use serde_json::json;
use serde::Serialize;
use serde_json::{Value, json};
use std::sync::atomic::{AtomicBool, Ordering};
use tauri::{AppHandle, Emitter, Runtime, command};
use crate::core::{device::{DeviceEvent, DeviceKind}};
use tauri::{AppHandle, Emitter};
static IS_RUNNING: AtomicBool = AtomicBool::new(false);
#[command]
pub async fn start_device_listening<R: Runtime>(app_handle: AppHandle<R>) -> Result<(), String> {
#[derive(Debug, Clone, Serialize)]
pub enum DeviceKind {
MousePress,
MouseRelease,
MouseMove,
KeyboardPress,
KeyboardRelease,
}
#[derive(Debug, Clone, Serialize)]
pub struct DeviceEvent {
kind: DeviceKind,
value: Value,
}
pub fn start_listening(app_handle: AppHandle) {
if IS_RUNNING.load(Ordering::SeqCst) {
return Err("Device is already listening".to_string());
return;
}
IS_RUNNING.store(true, Ordering::SeqCst);
let callback = move |event: Event| {
let device_event = match event.event_type {
let device = match event.event_type {
EventType::ButtonPress(button) => DeviceEvent {
kind: DeviceKind::MousePress,
value: json!(format!("{:?}", button)),
@@ -40,10 +53,20 @@ pub async fn start_device_listening<R: Runtime>(app_handle: AppHandle<R>) -> Res
_ => return,
};
let _ = app_handle.emit("device-changed", device_event);
if let Err(e) = app_handle.emit("device-changed", device) {
eprintln!("Failed to emit event: {:?}", e);
}
};
listen(callback).map_err(|err| format!("Failed to listen device: {:?}", err))?;
#[cfg(target_os = "macos")]
if let Err(e) = listen(callback) {
eprintln!("Device listening error: {:?}", e);
}
Ok(())
#[cfg(not(target_os = "macos"))]
std::thread::spawn(move || {
if let Err(e) = listen(callback) {
eprintln!("Device listening error: {:?}", e);
}
});
}

View File

@@ -1,128 +0,0 @@
use input::{
event::{
keyboard::{KeyState, KeyboardEventTrait},
pointer::ButtonState,
PointerEvent,
},
Event, Libinput, LibinputInterface,
};
use nix::{
libc::{O_RDONLY, O_RDWR, O_WRONLY},
poll::{poll, PollFd, PollFlags, PollTimeout},
};
use std::{
fs::{File, OpenOptions}, os::{fd::{AsFd, OwnedFd}, unix::prelude::OpenOptionsExt}, path::Path
};
use serde_json::json;
use std::sync::atomic::{AtomicBool, Ordering};
use tauri::{AppHandle, Emitter, Runtime, command};
use crate::core::{device::{DeviceEvent, DeviceKind}, setup::key_from_code};
static IS_RUNNING: AtomicBool = AtomicBool::new(false);
pub struct Interface;
impl LibinputInterface for Interface {
fn open_restricted(&mut self, path: &Path, flags: i32) -> Result<OwnedFd, i32> {
OpenOptions::new()
.custom_flags(flags)
.read((flags & O_RDONLY != 0) | (flags & O_RDWR != 0))
.write((flags & O_WRONLY != 0) | (flags & O_RDWR != 0))
.open(path)
.map(|file| file.into())
.map_err(|err| err.raw_os_error().unwrap())
}
#[allow(unused_must_use)]
fn close_restricted(&mut self, fd: OwnedFd) {
File::from(fd);
}
}
fn build_device_event(event: &Event) -> Option<DeviceEvent> {
match event {
Event::Keyboard(ev) => {
let key_code = ev.key();
let key_name = match key_from_code(key_code) {
Some(name) => name.to_string(),
None => format!("Unknown({})", key_code),
};
match ev.key_state() {
KeyState::Pressed => Some(DeviceEvent {
kind: DeviceKind::KeyboardPress,
value: json!(key_name),
}),
KeyState::Released => Some(DeviceEvent{
kind: DeviceKind::KeyboardRelease,
value: json!(key_name),
})
}
},
Event::Pointer(ev) => {
match ev {
PointerEvent::Button(e) => {
let btn_code = e.button();
let btn_name = match btn_code {
0x110 => String::from("Left"),
0x111 => String::from("Right"),
0x112 => String::from("Middle"),
_ => format!("Unknown({})", btn_code as u8),
};
match e.button_state() {
ButtonState::Pressed => Some(DeviceEvent {
kind: DeviceKind::MousePress,
value: json!(btn_name),
}),
ButtonState::Released => Some(DeviceEvent {
kind: DeviceKind::MouseRelease,
value: json!(btn_name),
})
}
},
PointerEvent::Motion(e) => {
Some(DeviceEvent {
kind: DeviceKind::MouseMove,
value: json!({
"x": e.dx_unaccelerated(),
"y": e.dy_unaccelerated()
}),
})
},
_ => None,
}
},
_ => None,
}
}
#[command]
pub async fn start_device_listening<R: Runtime>(app_handle: AppHandle<R>) -> Result<(), String> {
if IS_RUNNING.load(Ordering::SeqCst) {
return Err("Device is already listening".to_string());
}
IS_RUNNING.store(true, Ordering::SeqCst);
let mut input = Libinput::new_with_udev(Interface);
match input.udev_assign_seat("seat0") {
Ok(_) => {
let input_clone = &input.clone();
let mut pollfds = [PollFd::new(input_clone.as_fd(), PollFlags::POLLIN)];
while poll(&mut pollfds, PollTimeout::NONE).is_ok() {
input.dispatch().unwrap();
for event in &mut input {
let device_event = build_device_event(&event);
if let Some(e) = device_event {
app_handle.emit("device-changed", e).unwrap();
}
}
}
},
Err(_) => return Err("Failed to assign seat".to_string()),
}
Ok(())
}

View File

@@ -1,29 +0,0 @@
use serde::Serialize;
use serde_json::Value;
#[cfg(not(target_os = "linux"))]
pub mod common;
#[cfg(not(target_os = "linux"))]
pub use common::*;
#[cfg(target_os = "linux")]
pub mod linux;
#[cfg(target_os = "linux")]
pub use linux::*;
#[derive(Debug, Clone, Serialize)]
pub enum DeviceKind {
MousePress,
MouseRelease,
MouseMove,
KeyboardPress,
KeyboardRelease,
}
#[derive(Debug, Clone, Serialize)]
pub struct DeviceEvent {
kind: DeviceKind,
value: Value,
}

View File

@@ -6,149 +6,3 @@ pub fn platform(
_preference_window: WebviewWindow,
) {
}
pub fn key_from_code(code: u32) -> Option<&'static str> {
match code {
// Function key
1 => Some("Escape"),
28 => Some("Return"),
14 => Some("Backspace"),
15 => Some("Tab"),
57 => Some("Space"),
58 => Some("CapsLock"),
99 => Some("PrintScreen"),
70 => Some("ScrollLock"),
119 => Some("Pause"),
69 => Some("NumLock"),
110 => Some("Insert"),
102 => Some("Home"),
107 => Some("End"),
104 => Some("PageUp"),
109 => Some("PageDown"),
111 => Some("Delete"),
// Arrow key
103 => Some("UpArrow"),
108 => Some("DownArrow"),
105 => Some("LeftArrow"),
106 => Some("RightArrow"),
// F key
59 => Some("F1"),
60 => Some("F2"),
61 => Some("F3"),
62 => Some("F4"),
63 => Some("F5"),
64 => Some("F6"),
65 => Some("F7"),
66 => Some("F8"),
67 => Some("F9"),
68 => Some("F10"),
87 => Some("F11"),
88 => Some("F12"),
// Numeric
2 => Some("Num1"),
3 => Some("Num2"),
4 => Some("Num3"),
5 => Some("Num4"),
6 => Some("Num5"),
7 => Some("Num6"),
8 => Some("Num7"),
9 => Some("Num8"),
10 => Some("Num9"),
11 => Some("Num0"),
// Alphabetic
16 => Some("KeyQ"),
17 => Some("KeyW"),
18 => Some("KeyE"),
19 => Some("KeyR"),
20 => Some("KeyT"),
21 => Some("KeyY"),
22 => Some("KeyU"),
23 => Some("KeyI"),
24 => Some("KeyO"),
25 => Some("KeyP"),
30 => Some("KeyA"),
31 => Some("KeyS"),
32 => Some("KeyD"),
33 => Some("KeyF"),
34 => Some("KeyG"),
35 => Some("KeyH"),
36 => Some("KeyJ"),
37 => Some("KeyK"),
38 => Some("KeyL"),
44 => Some("KeyZ"),
45 => Some("KeyX"),
46 => Some("KeyC"),
47 => Some("KeyV"),
48 => Some("KeyB"),
49 => Some("KeyN"),
50 => Some("KeyM"),
// Symbolic
41 => Some("BackQuote"),
12 => Some("Minus"),
13 => Some("Equal"),
26 => Some("LeftBracket"),
27 => Some("RightBracket"),
39 => Some("SemiColon"),
40 => Some("Quote"),
43 => Some("BackSlash"),
86 => Some("IntlBackslash"),
89 => Some("IntlRo"),
124 => Some("IntlYen"),
101 => Some("KanaMode"),
51 => Some("Comma"),
52 => Some("Dot"),
53 => Some("Slash"),
// Control key
29 => Some("ControlLeft"),
97 => Some("ControlRight"),
42 => Some("ShiftLeft"),
54 => Some("ShiftRight"),
56 => Some("Alt"),
100 => Some("AltGr"),
125 => Some("MetaLeft"),
126 => Some("MetaRight"),
127 => Some("Apps"),
// NumPad
55 => Some("KpMultiply"),
78 => Some("KpMinus"),
74 => Some("KpPlus"),
98 => Some("KpDivide"),
117 => Some("KpEqual"),
121 => Some("KpComma"),
96 => Some("KpReturn"),
83 => Some("KpDecimal"),
79 => Some("Kp1"),
80 => Some("Kp2"),
81 => Some("Kp3"),
75 => Some("Kp4"),
76 => Some("Kp5"),
77 => Some("Kp6"),
71 => Some("Kp7"),
72 => Some("Kp8"),
73 => Some("Kp9"),
82 => Some("Kp0"),
// Media key
115 => Some("VolumeUp"),
114 => Some("VolumeDown"),
113 => Some("VolumeMute"),
// Language key
90 => Some("Lang1"),
91 => Some("Lang2"),
92 => Some("Lang3"),
93 => Some("Lang4"),
94 => Some("Lang5"),
// Other
_ => None,
}
}

View File

@@ -1,4 +1,3 @@
#![allow(deprecated)]
use tauri::{AppHandle, Emitter, EventTarget, WebviewWindow};
use tauri_nspanel::{WebviewWindowExt, cocoa::appkit::NSWindowCollectionBehavior, panel_delegate};
use tauri_plugin_custom_window::MAIN_WINDOW_LABEL;

View File

@@ -1,7 +1,7 @@
mod core;
mod utils;
use core::{device::start_device_listening, prevent_default, setup};
use core::{device, prevent_default, setup};
use tauri::{Manager, WindowEvent, generate_handler};
use tauri_plugin_autostart::MacosLauncher;
use tauri_plugin_custom_window::{
@@ -21,9 +21,11 @@ pub fn run() {
setup::default(&app_handle, main_window.clone(), preference_window.clone());
device::start_listening(app_handle.clone());
Ok(())
})
.invoke_handler(generate_handler![copy_dir, start_device_listening])
.invoke_handler(generate_handler![copy_dir])
.plugin(tauri_plugin_custom_window::init())
.plugin(tauri_plugin_os::init())
.plugin(tauri_plugin_process::init())

View File

@@ -1,4 +1,3 @@
#![allow(deprecated)]
use super::{is_main_window, shared_hide_window, shared_set_always_on_top, shared_show_window};
use crate::MAIN_WINDOW_LABEL;
use tauri::{AppHandle, Runtime, WebviewWindow, command};

View File

@@ -63,10 +63,8 @@
},
"plugins": {
"updater": {
"dangerousInsecureTransportProtocol": true,
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEVBRjJFMzE3MjEwMUZEMTAKUldRUS9RRWhGK1B5NmdkemhKcUFrVjZBQXlzdExpakdWVEJDeU9XckVsbzV2cFIycVJOempWa2UK",
"endpoints": [
"http://api.upgrade.toolsetlink.com/v1/tauri/upgrade?tauriKey=KtGlsZUVXmWfjkRKCuqpfw&versionName={{current_version}}&target={{target}}&arch={{arch}}&appointVersionName=&devModelKey=&devKey=",
"https://gh-proxy.com/github.com/ayangweb/BongoCat/releases/latest/download/latest.json"
]
},

View File

@@ -35,12 +35,12 @@ onMounted(async () => {
await appStore.$tauri.start()
await modelStore.$tauri.start()
await modelStore.init()
await catStore.$tauri.start()
await generalStore.$tauri.start()
await shortcutStore.$tauri.start()
await restoreState()
catStore.init()
catStore.visible = true
restoreState()
})
useTauriListen(LISTEN_KEY.SHOW_WINDOW, ({ payload }) => {

View File

@@ -11,7 +11,7 @@ import { computed, reactive, watch } from 'vue'
import VueMarkdown from 'vue-markdown-render'
import { useTauriListen } from '@/composables/useTauriListen'
import { GITHUB_LINK, LISTEN_KEY, UPGRADE_LINK_ACCESS_KEY } from '@/constants'
import { GITHUB_LINK, LISTEN_KEY } from '@/constants'
import { showWindow } from '@/plugins/window'
import { useGeneralStore } from '@/stores/general'
@@ -67,12 +67,7 @@ const downloadProgress = computed(() => {
async function checkUpdate(visibleMessage = false) {
try {
const update = await check({
timeout: 5000,
headers: {
'X-AccessKey': UPGRADE_LINK_ACCESS_KEY,
},
})
const update = await check()
if (update) {
const { version, currentVersion, body = '', date, downloadAndInstall } = update

View File

@@ -1,34 +1,33 @@
import { convertFileSrc } from '@tauri-apps/api/core'
import { LogicalSize, PhysicalSize } from '@tauri-apps/api/dpi'
import { resolveResource } from '@tauri-apps/api/path'
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow'
import { message } from 'ant-design-vue'
import { isNil, round } from 'es-toolkit'
import { ref, watch } from 'vue'
import { computed, watch } from 'vue'
import live2d from '../utils/live2d'
import { getCursorMonitor } from '../utils/monitor'
import { useCatStore } from '@/stores/cat'
import { useModelStore } from '@/stores/model'
import { getImageSize } from '@/utils/dom'
import { join } from '@/utils/path'
const appWindow = getCurrentWebviewWindow()
interface ModelSize {
width: number
height: number
}
export function useModel() {
const modelStore = useModelStore()
const catStore = useCatStore()
const modelSize = ref<ModelSize>()
const backgroundImage = computed(() => {
return convertFileSrc(join(modelStore.currentModel!.path, 'resources', 'background.png'))
})
watch(() => modelStore.currentModel, handleLoad, { deep: true, immediate: true })
watch([() => catStore.scale, modelSize], async () => {
if (!modelSize.value) return
const { width, height } = modelSize.value
watch(() => catStore.scale, async () => {
const { width, height } = await getImageSize(backgroundImage.value)
appWindow.setSize(
new PhysicalSize({
@@ -40,19 +39,15 @@ export function useModel() {
async function handleLoad() {
try {
if (!modelStore.currentModel) return
const { path } = modelStore.currentModel
const { path } = modelStore.currentModel!
await resolveResource(path)
const { width, height, ...rest } = await live2d.load(path)
await live2d.load(path)
modelSize.value = { width, height }
// handleResize()
handleResize()
Object.assign(modelStore, rest)
// Object.assign(modelStore, data)
} catch (error) {
message.error(String(error))
}
@@ -63,11 +58,13 @@ export function useModel() {
}
async function handleResize() {
if (!modelSize.value) return
if (!live2d.model) return
live2d.fitModel()
const { innerWidth, innerHeight } = window
const { width, height } = modelSize.value
const { width, height } = await getImageSize(backgroundImage.value)
live2d.model?.scale.set(innerWidth / width)
if (round(innerWidth / innerHeight, 1) !== round(width / height, 1)) {
await appWindow.setSize(
@@ -133,6 +130,7 @@ export function useModel() {
}
return {
backgroundImage,
handleLoad,
handleDestroy,
handleResize,

View File

@@ -8,7 +8,6 @@ import { TrayIcon } from '@tauri-apps/api/tray'
import { openUrl } from '@tauri-apps/plugin-opener'
import { exit, relaunch } from '@tauri-apps/plugin-process'
import { watchDebounced } from '@vueuse/core'
import { watch } from 'vue'
import { GITHUB_LINK, LISTEN_KEY } from '../constants'
import { showWindow } from '../plugins/window'
@@ -24,13 +23,9 @@ export function useTray() {
const catStore = useCatStore()
const { getSharedMenu } = useSharedMenu()
watch([() => catStore.visible, () => catStore.penetrable], () => {
watchDebounced(() => catStore, () => {
updateTrayMenu()
})
watchDebounced([() => catStore.scale, () => catStore.opacity], () => {
updateTrayMenu()
}, { debounce: 200 })
}, { deep: true, debounce: 500 })
const createTray = async () => {
const tray = await getTrayById()

View File

@@ -1,7 +1,5 @@
export const GITHUB_LINK = 'https://github.com/ayangweb/BongoCat'
export const UPGRADE_LINK_ACCESS_KEY = 'xDbrq2rOoRThDqKOHL2ZRA'
export const LISTEN_KEY = {
SHOW_WINDOW: 'show-window',
HIDE_WINDOW: 'hide-window',
@@ -11,5 +9,4 @@ export const LISTEN_KEY = {
export const INVOKE_KEY = {
COPY_DIR: 'copy_dir',
START_DEVICE_LISTENING: 'start_device_listening',
}

View File

@@ -1,15 +1,13 @@
<script setup lang="ts">
import { convertFileSrc, invoke } from '@tauri-apps/api/core'
import { convertFileSrc } from '@tauri-apps/api/core'
import { Menu } from '@tauri-apps/api/menu'
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow'
import { exists } from '@tauri-apps/plugin-fs'
import { useDebounceFn, useEventListener } from '@vueuse/core'
import { onMounted, onUnmounted, ref, watch } from 'vue'
import { onUnmounted, ref, watch } from 'vue'
import { useDevice } from '@/composables/useDevice'
import { useModel } from '@/composables/useModel'
import { useSharedMenu } from '@/composables/useSharedMenu'
import { INVOKE_KEY } from '@/constants'
import { hideWindow, setAlwaysOnTop, showWindow } from '@/plugins/window'
import { useCatStore } from '@/stores/cat'
import { useModelStore } from '@/stores/model'
@@ -17,20 +15,15 @@ import { join } from '@/utils/path'
const appWindow = getCurrentWebviewWindow()
const { pressedMouses, mousePosition, pressedLeftKeys, pressedRightKeys } = useDevice()
const { handleDestroy, handleResize, handleMouseDown, handleMouseMove, handleKeyDown } = useModel()
const { backgroundImage, handleDestroy, handleResize, handleMouseDown, handleMouseMove, handleKeyDown } = useModel()
const catStore = useCatStore()
const { getSharedMenu } = useSharedMenu()
const modelStore = useModelStore()
const resizing = ref(false)
const backgroundImagePath = ref<string>()
onMounted(() => {
invoke(INVOKE_KEY.START_DEVICE_LISTENING)
})
onUnmounted(handleDestroy)
const debouncedResize = useDebounceFn(async () => {
const handleDebounceResize = useDebounceFn(async () => {
await handleResize()
resizing.value = false
@@ -39,7 +32,7 @@ const debouncedResize = useDebounceFn(async () => {
useEventListener('resize', () => {
resizing.value = true
debouncedResize()
handleDebounceResize()
})
watch(pressedMouses, handleMouseDown)
@@ -64,16 +57,6 @@ watch(() => catStore.penetrable, (value) => {
watch(() => catStore.alwaysOnTop, setAlwaysOnTop, { immediate: true })
watch(() => modelStore.currentModel, async (model) => {
if (!model) return
const path = join(model.path, 'resources', 'background.png')
const existed = await exists(path)
backgroundImagePath.value = existed ? convertFileSrc(path) : void 0
}, { deep: true, immediate: true })
function handleWindowDrag() {
appWindow.startDragging()
}
@@ -88,7 +71,7 @@ async function handleContextmenu(event: MouseEvent) {
menu.popup()
}
function resolveKeyImagePath(key: string, side: 'left' | 'right' = 'left') {
function resolveImagePath(key: string, side: 'left' | 'right' = 'left') {
return convertFileSrc(join(modelStore.currentModel!.path, 'resources', `${side}-keys`, `${key}.png`))
}
</script>
@@ -96,28 +79,25 @@ function resolveKeyImagePath(key: string, side: 'left' | 'right' = 'left') {
<template>
<div
class="relative size-screen overflow-hidden children:(absolute size-full)"
:class="{ '-scale-x-100': catStore.mirrorMode }"
:class="[catStore.mirrorMode ? '-scale-x-100' : 'scale-x-100']"
:style="{ opacity: catStore.opacity / 100 }"
@contextmenu="handleContextmenu"
@mousedown="handleWindowDrag"
>
<img
v-if="backgroundImagePath"
:src="backgroundImagePath"
>
<img :src="backgroundImage">
<canvas id="live2dCanvas" />
<img
v-for="key in pressedLeftKeys"
:key="key"
:src="resolveKeyImagePath(key)"
:src="resolveImagePath(key)"
>
<img
v-for="key in pressedRightKeys"
:key="key"
:src="resolveKeyImagePath(key, 'right')"
:src="resolveImagePath(key, 'right')"
>
<div

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { InputNumber, Slider, Switch } from 'ant-design-vue'
import { Slider, Switch } from 'ant-design-vue'
import ProList from '@/components/pro-list/index.vue'
import ProListItem from '@/components/pro-list-item/index.vue'
@@ -7,6 +7,10 @@ import { useCatStore } from '@/stores/cat'
const catStore = useCatStore()
function scaleFormatter(value?: number) {
return value === 100 ? '默认' : `${value}%`
}
function opacityFormatter(value?: number) {
return `${value}%`
}
@@ -54,16 +58,15 @@ function opacityFormatter(value?: number) {
<ProListItem
description="将鼠标移动到窗口边缘后,也可以拖动调整窗口尺寸"
title="窗口尺寸"
vertical
>
<InputNumber
<Slider
v-model:value="catStore.scale"
class="w-28"
:min="1"
>
<template #addonAfter>
%
</template>
</InputNumber>
class="m-0!"
:max="150"
:min="50"
:tip-formatter="scaleFormatter"
/>
</ProListItem>
<ProListItem

View File

@@ -86,7 +86,7 @@ watch(selectPaths, async (paths) => {
<template>
<div
ref="drop"
class="w-full flex flex-col cursor-pointer items-center justify-center gap-4 b b-color-1 rounded-lg b-dashed bg-color-8 transition hover:border-primary"
class="size-full flex flex-col cursor-pointer items-center justify-center gap-4 b b-color-1 rounded-lg b-dashed bg-color-8 transition hover:border-primary"
:class="{ 'border-primary': dragenter }"
@click="handleUpload"
>

View File

@@ -1,14 +1,11 @@
<script setup lang="ts">
import type { Model } from '@/stores/model'
import type { ComponentPublicInstance } from 'vue'
import type { ColProps } from 'ant-design-vue'
import { convertFileSrc } from '@tauri-apps/api/core'
import { remove } from '@tauri-apps/plugin-fs'
import { revealItemInDir } from '@tauri-apps/plugin-opener'
import { useElementSize } from '@vueuse/core'
import { Card, message, Popconfirm } from 'ant-design-vue'
import { ref } from 'vue'
import { MasonryGrid, MasonryGridItem } from 'vue3-masonry-css'
import { Card, Col, message, Popconfirm, Row } from 'ant-design-vue'
import FloatMenu from './components/float-menu/index.vue'
import Upload from './components/upload/index.vue'
@@ -17,20 +14,12 @@ import { useModelStore } from '@/stores/model'
import { join } from '@/utils/path'
const modelStore = useModelStore()
const firstItemRef = ref<HTMLElement>()
const { height } = useElementSize(firstItemRef)
function setFirstItemRef(el: Element | ComponentPublicInstance | null, index: number) {
if (!el || index > 0) return
if ('$el' in el) {
return firstItemRef.value = el.$el
}
if (el instanceof HTMLElement) {
firstItemRef.value = el
}
const colProps: ColProps = {
xs: 12,
md: 8,
lg: 6,
xl: 4,
}
async function handleDelete(item: Model) {
@@ -53,23 +42,19 @@ async function handleDelete(item: Model) {
</script>
<template>
<MasonryGrid
:columns="{ 992: 3, 1200: 4, 1600: 6, default: 8 }"
:gutter="16"
>
<MasonryGridItem>
<Upload :style="{ height: `${height}px` }" />
</MasonryGridItem>
<Row :gutter="[16, 16]">
<Col v-bind="colProps">
<Upload />
</Col>
<MasonryGridItem
v-for="(item, index) in modelStore.models"
<Col
v-for="item in modelStore.models"
:key="item.id"
v-bind="colProps"
>
<Card
:ref="(el) => setFirstItemRef(el, index)"
hoverable
size="small"
@click="modelStore.currentModel = item"
>
<template #cover>
<img
@@ -82,11 +67,12 @@ async function handleDelete(item: Model) {
<i
class="i-iconamoon:check-circle-1-bold text-4"
:class="{ 'text-success': item.id === modelStore.currentModel?.id }"
@click="modelStore.currentModel = item"
/>
<i
class="i-iconamoon:link-external-bold text-4"
@click.stop="revealItemInDir(item.path)"
@click="revealItemInDir(item.path)"
/>
<template v-if="!item.isPreset">
@@ -96,16 +82,13 @@ async function handleDelete(item: Model) {
title="删除模型"
@confirm="handleDelete(item)"
>
<i
class="i-iconamoon:trash-simple-bold text-4"
@click.stop
/>
<i class="i-iconamoon:trash-simple-bold text-4" />
</Popconfirm>
</template>
</template>
</Card>
</MasonryGridItem>
</MasonryGrid>
</Col>
</Row>
<FloatMenu />
</template>

View File

@@ -61,7 +61,6 @@ const menus = [
<div class="b b-color-2 rounded-2xl b-solid">
<img
class="size-15"
data-tauri-drag-region
src="/logo.png"
>
</div>

View File

@@ -11,10 +11,6 @@ export const useCatStore = defineStore('cat', () => {
const scale = ref(100)
const opacity = ref(100)
const init = () => {
visible.value = true
}
return {
visible,
mirrorMode,
@@ -24,6 +20,5 @@ export const useCatStore = defineStore('cat', () => {
alwaysOnTop,
scale,
opacity,
init,
}
})

View File

@@ -1,7 +1,7 @@
import { resolveResource } from '@tauri-apps/api/path'
import { nanoid } from 'nanoid'
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { onMounted, ref } from 'vue'
import { join } from '@/utils/path'
@@ -37,47 +37,33 @@ export const useModelStore = defineStore('model', () => {
const motions = ref<MotionGroup>({})
const expressions = ref<Expression[]>([])
const init = async () => {
const presetModelsPath = await resolveResource('assets/models')
onMounted(async () => {
const modelsPath = await resolveResource('assets/models')
if (models.value.length === 0) {
const modes: ModelMode[] = ['standard', 'keyboard']
for (const mode of modes) {
for await (const mode of modes) {
const path = join(modelsPath, mode)
models.value.push({
mode,
id: nanoid(),
path,
mode,
isPreset: true,
path: join(presetModelsPath, mode),
})
}
} else {
models.value = models.value.map((item) => {
const { isPreset, mode } = item
if (!isPreset) return item
return {
...item,
path: join(presetModelsPath, mode),
}
})
}
const matched = models.value.find(item => item.id === currentModel.value?.id)
if (matched) {
return currentModel.value = matched
}
if (currentModel.value) return
currentModel.value = models.value[0]
}
})
return {
models,
currentModel,
motions,
expressions,
init,
}
})

15
src/utils/dom.ts Normal file
View File

@@ -0,0 +1,15 @@
export function getImageSize(src: string) {
return new Promise<{ width: number, height: number }>((resolve, reject) => {
const img = new Image()
img.src = src
img.onload = () => {
const { naturalWidth, naturalHeight } = img
resolve({ width: naturalWidth, height: naturalHeight })
}
img.onerror = reject
})
}

110
src/utils/live2d copy.ts Normal file
View File

@@ -0,0 +1,110 @@
import type { Cubism4InternalModel } from 'pixi-live2d-display'
import { convertFileSrc } from '@tauri-apps/api/core'
import { readDir, readTextFile } from '@tauri-apps/plugin-fs'
import { Cubism4ModelSettings, Live2DModel } from 'pixi-live2d-display'
import { Application, Ticker } from 'pixi.js'
import { join } from './path'
Live2DModel.registerTicker(Ticker)
class Live2d {
private app: Application | null = null
public model: Live2DModel | null = null
constructor() { }
private mount() {
const view = document.getElementById('live2dCanvas') as HTMLCanvasElement
this.app = new Application({
view,
resizeTo: window,
backgroundAlpha: 0,
autoDensity: true,
resolution: devicePixelRatio,
})
}
public async load(path: string) {
if (!this.app) {
this.mount()
}
this.destroy()
const files = await readDir(path)
const modelFile = files.find(file => file.name.endsWith('.model3.json'))
if (!modelFile) {
throw new Error('未找到模型主配置文件,请确认模型文件是否完整。')
}
const modelPath = join(path, modelFile.name)
const modelJSON = JSON.parse(await readTextFile(modelPath))
const modelSettings = new Cubism4ModelSettings({
...modelJSON,
url: convertFileSrc(modelPath),
})
modelSettings.replaceFiles((file) => {
return convertFileSrc(join(path, file))
})
this.model = await Live2DModel.from(modelSettings)
this.app?.stage.addChild(this.model)
const { motions, expressions } = modelSettings
return {
motions,
expressions,
}
}
public destroy() {
this.model?.destroy()
}
public playMotion(group: string, index: number) {
return this.model?.motion(group, index)
}
public playExpressions(index: number) {
return this.model?.expression(index)
}
public getCoreModel() {
const internalModel = this.model?.internalModel as Cubism4InternalModel
return internalModel?.coreModel
}
public getParameterRange(id: string) {
const coreModel = this.getCoreModel()
const index = coreModel?.getParameterIndex(id)
const min = coreModel?.getParameterMinimumValue(index)
const max = coreModel?.getParameterMaximumValue(index)
return {
min,
max,
}
}
public setParameterValue(id: string, value: number) {
const coreModel = this.getCoreModel()
return coreModel?.setParameterValueById?.(id, Number(value))
}
}
const live2d = new Live2d()
export default live2d

View File

@@ -1,31 +1,34 @@
import type { Cubism4InternalModel } from 'pixi-live2d-display'
import type { Sprite } from 'pixi.js'
import { convertFileSrc } from '@tauri-apps/api/core'
import { readDir, readTextFile } from '@tauri-apps/plugin-fs'
import { Cubism4ModelSettings, Live2DModel } from 'pixi-live2d-display'
import { Config, CubismSetting, Live2DSprite, LogLevel } from 'easy-live2d'
import { Application, Ticker } from 'pixi.js'
import { join } from './path'
Live2DModel.registerTicker(Ticker)
Config.MotionGroupIdle = 'Idle'
Config.MouseFollow = false
Config.CubismLoggingLevel = LogLevel.LogLevel_Off
class Live2d {
private app: Application | null = null
public model: Live2DModel | null = null
private modelWidth = 0
private modelHeight = 0
public model: Sprite | null = null
constructor() { }
private initApp() {
private async initApp() {
if (this.app) return
const view = document.getElementById('live2dCanvas') as HTMLCanvasElement
this.app = new Application({
this.app = new Application()
await this.app.init({
view,
resizeTo: window,
backgroundAlpha: 0,
autoDensity: true,
resolution: devicePixelRatio,
})
}
@@ -47,81 +50,64 @@ class Live2d {
const modelJSON = JSON.parse(await readTextFile(modelPath))
const modelSettings = new Cubism4ModelSettings({
...modelJSON,
url: convertFileSrc(modelPath),
const modelSetting = new CubismSetting({
modelJSON,
})
modelSettings.replaceFiles((file) => {
modelSetting.redirectPath(({ file }) => {
return convertFileSrc(join(path, file))
})
this.model = await Live2DModel.from(modelSettings)
const { width, height } = this.model
this.modelWidth = width
this.modelHeight = height
this.model = new Live2DSprite({
modelSetting,
ticker: Ticker.shared,
})
this.app?.stage.addChild(this.model)
const { motions, expressions } = modelSettings
// const { motions, expressions } = modelSettings
return {
width,
height,
motions,
expressions,
}
// return {
// motions,
// expressions,
// }
}
public destroy() {
this.model?.destroy()
}
public fitModel() {
if (!this.model) return
const scaleX = innerWidth / this.modelWidth
const scaleY = innerHeight / this.modelHeight
const scale = Math.min(scaleX, scaleY)
this.model.scale.set(scale)
this.model.x = innerWidth / 2
this.model.y = innerHeight / 2
this.model.anchor.set(0.5)
}
public playMotion(group: string, index: number) {
return this.model?.motion(group, index)
// return this.model?.motion(group, index)
}
public playExpressions(index: number) {
return this.model?.expression(index)
// return this.model?.expression(index)
}
public getCoreModel() {
const internalModel = this.model?.internalModel as Cubism4InternalModel
// const internalModel = this.model?.internalModel as Cubism4InternalModel
return internalModel?.coreModel
// return internalModel?.coreModel
}
public getParameterRange(id: string) {
const coreModel = this.getCoreModel()
// const coreModel = this.getCoreModel()
const index = coreModel?.getParameterIndex(id)
const min = coreModel?.getParameterMinimumValue(index)
const max = coreModel?.getParameterMaximumValue(index)
// const index = coreModel?.getParameterIndex(id)
// const min = coreModel?.getParameterMinimumValue(index)
// const max = coreModel?.getParameterMaximumValue(index)
return {
min,
max,
min: 0,
max: 1,
}
}
public setParameterValue(id: string, value: number) {
const coreModel = this.getCoreModel()
// const coreModel = this.getCoreModel()
return coreModel?.setParameterValueById?.(id, Number(value))
// return coreModel?.setParameterValueById?.(id, Number(value))
}
}