├── .github └── workflows │ ├── build.yaml │ └── check.yaml ├── .gitignore ├── .vscode └── launch.json ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── assets ├── icons │ ├── address-book-symbolic.svg │ ├── chat-symbolic.svg │ ├── keyboard-symbolic.svg │ ├── menu-symbolic.svg │ ├── people-symbolic.svg │ ├── person2-symbolic.svg │ ├── qr-code-symbolic.svg │ └── send-symbolic.svg ├── meson.build └── resources.gresource.xml ├── docs ├── dark.png └── light.png ├── libs ├── resource-loader │ ├── .gitignore │ ├── Cargo.toml │ └── src │ │ ├── configs │ │ ├── avatar.rs │ │ ├── client.rs │ │ ├── config.rs │ │ ├── local_db.rs │ │ ├── mod.rs │ │ └── temporary.rs │ │ ├── lib.rs │ │ ├── ops │ │ ├── avatar.rs │ │ ├── client.rs │ │ ├── database.rs │ │ ├── mod.rs │ │ └── temporary.rs │ │ ├── resource_directories.rs │ │ ├── static_data.rs │ │ └── utils.rs └── widgets │ ├── Cargo.toml │ └── src │ ├── lib.rs │ ├── link_copier │ ├── component.rs │ ├── mod.rs │ ├── payloads.rs │ └── widgets.rs │ ├── pwd_login │ ├── component.rs │ ├── mod.rs │ ├── payloads.rs │ └── widgets.rs │ └── qrcode_login │ ├── component.rs │ ├── mod.rs │ ├── payloads.rs │ └── widgets.rs ├── meson.build └── src ├── actions.rs ├── app ├── login │ ├── captcha │ │ └── mod.rs │ ├── device_lock │ │ └── mod.rs │ ├── mod.rs │ ├── service.rs │ └── service │ │ ├── handle_respond.rs │ │ ├── login_server.rs │ │ ├── pwd_login.rs │ │ └── token.rs ├── main │ ├── chatroom │ │ ├── message_group.rs │ │ └── mod.rs │ ├── mod.rs │ └── sidebar │ │ ├── chats │ │ ├── chat_item.rs │ │ └── mod.rs │ │ ├── contact │ │ ├── friends │ │ │ ├── friends_group.rs │ │ │ ├── mod.rs │ │ │ └── search_item.rs │ │ ├── groups │ │ │ ├── group_item.rs │ │ │ └── mod.rs │ │ └── mod.rs │ │ └── mod.rs └── mod.rs ├── config.rs.in ├── db ├── fs.rs ├── mod.rs └── sql.rs ├── global └── mod.rs ├── handler.rs ├── main.rs ├── meson.build ├── styles └── style.css └── utils ├── avatar ├── error.rs ├── loader │ ├── group.rs │ ├── mod.rs │ └── user.rs └── mod.rs ├── message ├── content.rs ├── mod.rs └── utils.rs └── mod.rs /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths-ignore: 8 | - '**.md' 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-22.04 13 | steps: 14 | - uses: actions/checkout@v2 15 | - uses: actions-rs/toolchain@v1 16 | with: 17 | toolchain: nightly 18 | default: true 19 | - run: sudo apt-get update 20 | - run: sudo apt-get install meson ninja-build libgtk-4-dev libadwaita-1-dev 21 | - run: meson setup builddir 22 | - run: meson compile -C builddir 23 | - run: cargo build --release 24 | - uses: actions/upload-artifact@v3 25 | with: 26 | name: gtk-qq 27 | path: target/release/gtk-qq 28 | -------------------------------------------------------------------------------- /.github/workflows/check.yaml: -------------------------------------------------------------------------------- 1 | name: check 2 | 3 | on: 4 | push: 5 | paths-ignore: 6 | - '**.md' 7 | branches: 8 | - main 9 | pull_request: 10 | paths-ignore: 11 | - '**.md' 12 | 13 | jobs: 14 | check: 15 | runs-on: ubuntu-22.04 16 | env: 17 | RUSTFLAGS: -D warnings 18 | steps: 19 | - uses: actions/checkout@v2 20 | - uses: actions-rs/toolchain@v1 21 | with: 22 | toolchain: nightly 23 | components: clippy, rustfmt 24 | default: true 25 | - run: sudo apt-get update 26 | - run: sudo apt-get install meson ninja-build libgtk-4-dev libadwaita-1-dev 27 | - run: meson setup builddir 28 | - run: meson compile -C builddir 29 | - run: cargo fmt --check 30 | - run: cargo clippy 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | target 3 | 4 | # Generated by Meson 5 | builddir 6 | src/config.rs 7 | 8 | # Generated by RICQ 9 | device.json -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "lldb", 9 | "request": "launch", 10 | "name": "Debug executable 'gtk-qq'", 11 | "cargo": { 12 | "args": [ 13 | "build", 14 | "--bin=gtk-qq", 15 | "--package=gtk-qq" 16 | ], 17 | "filter": { 18 | "name": "gtk-qq", 19 | "kind": "bin" 20 | } 21 | }, 22 | "args": [], 23 | "cwd": "${workspaceFolder}" 24 | }, 25 | { 26 | "type": "lldb", 27 | "request": "launch", 28 | "name": "Debug unit tests in executable 'gtk-qq'", 29 | "cargo": { 30 | "args": [ 31 | "test", 32 | "--no-run", 33 | "--bin=gtk-qq", 34 | "--package=gtk-qq" 35 | ], 36 | "filter": { 37 | "name": "gtk-qq", 38 | "kind": "bin" 39 | } 40 | }, 41 | "args": [], 42 | "cwd": "${workspaceFolder}" 43 | } 44 | ] 45 | } -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "gtk-qq" 3 | version = "0.2.0" 4 | edition = "2021" 5 | 6 | [workspace] 7 | members = ["./libs/*"] 8 | 9 | [dependencies.relm4] 10 | git = "https://github.com/Relm4/Relm4.git" 11 | # next, 2022/05/30 12 | rev = "144f48319ffd7a889f28853df00e802cfc97dc26" 13 | features = ["macros", "libadwaita"] 14 | 15 | [dependencies.ricq] 16 | git = "https://github.com/lz1998/ricq.git" 17 | # v0.1.17, master, 2022/09/07 18 | rev = "56620d755f35f7b6ade52991be62360b3377547c" 19 | 20 | [dependencies.widgets] 21 | path = "./libs/widgets" 22 | 23 | [dependencies.resource-loader] 24 | path = "./libs/resource-loader" 25 | 26 | [dependencies] 27 | tokio = { version = "1.18.2", features = ["sync"] } 28 | rand = "0.8.5" 29 | async-trait = "0.1.53" 30 | once_cell = "1.11.0" 31 | rusqlite = "0.27.0" 32 | reqwest = "0.11.10" 33 | qrcode-png = "0.4.0" 34 | typed-builder = "0.10" 35 | bincode = "1.3.3" 36 | base64 = "0.13.0" 37 | 38 | [profile.release] 39 | lto = true 40 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GTK QQ 2 | 3 | [![license-badge]][license-link] 4 | [![dependency-badge]][dependency-link] 5 | [![check-badge]][check-link] 6 | [![build-badge]][build-link] 7 | 8 | [license-badge]: https://img.shields.io/badge/License-AGPL%20v3-blue.svg 9 | [license-link]: https://www.gnu.org/licenses/agpl-3.0 10 | [dependency-badge]: https://deps.rs/repo/github/lomirus/gtk-qq/status.svg 11 | [dependency-link]: https://deps.rs/repo/github/lomirus/gtk-qq 12 | [check-badge]: https://github.com/lomirus/gtk-qq/workflows/check/badge.svg 13 | [check-link]: https://github.com/lomirus/gtk-qq/actions/workflows/check.yaml 14 | [build-badge]: https://github.com/lomirus/gtk-qq/workflows/build/badge.svg 15 | [build-link]: https://github.com/lomirus/gtk-qq/actions/workflows/build.yaml 16 | 17 | 18 | Unofficial Linux [QQ](https://im.qq.com/) client, based on GTK4 and libadwaita, developed with Rust and [Relm4](https://relm4.org/). 19 | 20 | This app uses [ricq](https://github.com/lz1998/ricq) as the rust bindings of QQ APIs. 21 | 22 | ## Current Status 23 | 24 | > **Warning** 25 | > 26 | > This project has been discontinued due to following reasons: 27 | > - Tencent has released the official [Linux QQ](https://im.qq.com/linuxqq/index.shtml) in early 2023. Therefore this project may have copyright issues with it if continues. 28 | > - This project is based on the `gtk-rs`. To be honest, it is a disastrous development experience with it. If I could restart this project, I would not choose this library anymore. I've tried Tauri before when it's only in v0.x, the development experience is fine but the webview it uses on linux is too old to support many new features on modern browers now at that time. 29 | > - It is a very rare skill to develop app with `gtk-rs`. Only few people are able to contribute to this project, and the code quality is far from what I expected. 30 | > - The owner and main maintainer of this repository @lomirus is busy with some other projects and affairs IRL, and he doesn't use QQ on Linux very much now. So for now, he doesn't have enough motivation on this project. 31 | > 32 | > However, you can still create any pull request if you want. And if you want to find any alternative, [Icalingua plus plus](https://github.com/Icalingua-plus-plus/Icalingua-plus-plus) would be a good choice (compared with the official one :p) 33 | 34 | ## Screenshots 35 | 36 | | Light | Dark | 37 | | ------------------------------------------ | ---------------------------------------- | 38 | | ![Light Mode Screenshot](./docs/light.png) | ![Dark Mode Screenshot](./docs/dark.png) | 39 | 40 | > **Note** 41 | > 42 | > The two screenshots have been a little outdated. The UI now has been adjusted and improved compared to them. 43 | 44 | ## Installation 45 | 46 | ### AUR 47 | 48 | For Arch users, you can install via the AUR package [gtk-qq-git](https://aur.archlinux.org/packages/gtk-qq-git): 49 | 50 | ``` 51 | paru -S gtk-qq-git 52 | ``` 53 | 54 | ## Manual Build 55 | 56 | ### Requirements 57 | 58 | You will need to install [Rust](https://www.rust-lang.org/tools/install) and [Meson](https://mesonbuild.com/Getting-meson.html) to build this project, and the neccessary libraries below: 59 | 60 | #### Ubuntu (>= 22.04) 61 | 62 | ```bash 63 | sudo apt install gcc libssl-dev libsqlite3-dev libgtk-4-dev libadwaita-1-dev 64 | ``` 65 | 66 | #### Fedora 67 | 68 | ```bash 69 | sudo dnf install gtk4-devel libadwaita-devel 70 | ``` 71 | 72 | #### Arch 73 | 74 | ```bash 75 | sudo pacman -S pkgconf gtk4 libadwaita 76 | ``` 77 | 78 | #### Windows & MacOS 79 | 80 | GTK4 projects would be more complex to compile on Windows/MacOS. Nevertheless, considering some special reasons that you know, we shall not offer the Windows/MacOS release or even build scripts. 81 | 82 | > **Warning** 83 | > 84 | > You can try to build it still if you are just for personal use. At the same time, you should also promise that you will not distribute the Windows/MacOS build to the public in order to ensure the maintenance of this project. 85 | > 86 | > The user builds, uses or distributes this project at the user's own risk. This project and its contributors assume no responsibility. 87 | 88 | ### Setup 89 | 90 | You only need to run the commands below once unless you change the related codes. 91 | 92 | ```bash 93 | # In the root directory of project 94 | meson setup builddir 95 | meson compile -C builddir 96 | ``` 97 | 98 | ### Build 99 | 100 | Switch to nightly toolchain before building. 101 | 102 | ```bash 103 | # In the root directory of project 104 | rustup override set nightly 105 | cargo build --release 106 | ``` 107 | 108 | ## Contributing 109 | 110 | - You can feel free to use English or Chinese to open an issue or pull request. 111 | - The commit message should follow the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/). 112 | - If you want make changes to the UI part, read the [GNOME Human Interface Guidelines](https://developer.gnome.org/hig/index.html) before it. 113 | 114 | ## License 115 | 116 | This repository is under the [AGPL-3.0 license ](https://github.com/lomirus/gtk-qq/blob/main/LICENSE). 117 | -------------------------------------------------------------------------------- /assets/icons/address-book-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /assets/icons/chat-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /assets/icons/keyboard-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /assets/icons/menu-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /assets/icons/people-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /assets/icons/person2-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /assets/icons/qr-code-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /assets/icons/send-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /assets/meson.build: -------------------------------------------------------------------------------- 1 | gnome = import('gnome') 2 | 3 | resources = gnome.compile_resources( 4 | 'resources', 5 | 'resources.gresource.xml', 6 | gresource_bundle: true, 7 | source_dir: meson.current_build_dir(), 8 | ) 9 | -------------------------------------------------------------------------------- /assets/resources.gresource.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | icons/chat-symbolic.svg 5 | icons/address-book-symbolic.svg 6 | icons/send-symbolic.svg 7 | icons/menu-symbolic.svg 8 | icons/people-symbolic.svg 9 | icons/person2-symbolic.svg 10 | icons/keyboard-symbolic.svg 11 | icons/qr-code-symbolic.svg 12 | 13 | 14 | -------------------------------------------------------------------------------- /docs/dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lomirus/gtk-qq/ac4ee7627b402c849b1aa9e16186bad97c08e6b2/docs/dark.png -------------------------------------------------------------------------------- /docs/light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lomirus/gtk-qq/ac4ee7627b402c849b1aa9e16186bad97c08e6b2/docs/light.png -------------------------------------------------------------------------------- /libs/resource-loader/.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /Cargo.lock 3 | -------------------------------------------------------------------------------- /libs/resource-loader/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "resource-loader" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [features] 7 | default = [] 8 | logger = ["log"] 9 | 10 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 11 | [dependencies] 12 | serde = { version = "1", features = ["derive"] } 13 | once_cell = "1.11" 14 | derivative = "2.2" 15 | log = { version = "0.4", optional = true } 16 | tokio = { version = "1", features = ["fs"] } 17 | tempfile = "3.3" 18 | toml = "0.5.9" 19 | tap = "1" 20 | rand = "0.8.5" 21 | directories = "4" 22 | 23 | [dev-dependencies] 24 | serde_json = "1" 25 | 26 | [dependencies.ricq] 27 | git = "https://github.com/lz1998/ricq.git" 28 | # v0.1.17, master, 2022/09/07 29 | rev = "56620d755f35f7b6ade52991be62360b3377547c" -------------------------------------------------------------------------------- /libs/resource-loader/src/configs/avatar.rs: -------------------------------------------------------------------------------- 1 | use std::path::Path; 2 | 3 | use serde::{Deserialize, Serialize}; 4 | 5 | use crate::resource_directories::ResourceDirectories; 6 | 7 | use super::{free_path_ref, static_leak}; 8 | 9 | default_string! { 10 | BaseDir => "avatars" 11 | Group => "groups" 12 | User => "users" 13 | } 14 | 15 | #[derive(Debug, PartialEq, Eq, Serialize, Deserialize, derivative::Derivative)] 16 | #[derivative(Default)] 17 | pub struct AvatarConfig { 18 | #[derivative(Default(value = r#"BaseDir::get_default()"#))] 19 | #[serde(default = "BaseDir::get_default")] 20 | #[serde(alias = "base")] 21 | base_dir: String, 22 | #[derivative(Default(value = r#"Group::get_default()"#))] 23 | #[serde(default = "Group::get_default")] 24 | group: String, 25 | #[derivative(Default(value = r#"User::get_default()"#))] 26 | #[serde(default = "User::get_default")] 27 | user: String, 28 | } 29 | 30 | /// # Panic 31 | /// using string literal construct this struct will cause 32 | /// ***STATUS_HEAP_CORRUPTION***, 33 | /// 34 | /// the internal `& 'static Path` comes from `Box::leak` 35 | #[derive(Debug, PartialEq, Eq)] 36 | pub(crate) struct InnerAvatarConfig { 37 | pub group: &'static Path, 38 | pub user: &'static Path, 39 | } 40 | impl AvatarConfig { 41 | pub(crate) fn into_inner(self, root: &ResourceDirectories) -> InnerAvatarConfig { 42 | let avatar = root.get_data_home().join(&self.base_dir); 43 | 44 | let group = avatar.join(&self.group); 45 | let static_group = static_leak(group.into_boxed_path()); 46 | 47 | let user = avatar.join(&self.user); 48 | let static_user = static_leak(user.into_boxed_path()); 49 | 50 | InnerAvatarConfig { 51 | group: static_group, 52 | user: static_user, 53 | } 54 | } 55 | } 56 | 57 | impl Drop for InnerAvatarConfig { 58 | fn drop(&mut self) { 59 | free_path_ref(self.group); 60 | free_path_ref(self.user); 61 | } 62 | } 63 | 64 | #[cfg(test)] 65 | mod test { 66 | 67 | use std::path::Path; 68 | 69 | use serde_json::json; 70 | 71 | use crate::resource_directories::ResourceDirectories; 72 | 73 | use super::{AvatarConfig, BaseDir, Group, User}; 74 | 75 | #[test] 76 | fn test_default_data() { 77 | let avatar = AvatarConfig::default(); 78 | 79 | assert_eq!( 80 | avatar, 81 | AvatarConfig { 82 | base_dir: BaseDir::get_default(), 83 | group: Group::get_default(), 84 | user: User::get_default() 85 | } 86 | ) 87 | } 88 | #[test] 89 | fn test_not_full() { 90 | let data = json! { 91 | { 92 | "base_dir":"avatar_cache", 93 | "group":"group_avatar" 94 | } 95 | }; 96 | 97 | let avatar = serde_json::from_value::(data).unwrap(); 98 | 99 | assert_eq!( 100 | avatar, 101 | AvatarConfig { 102 | base_dir: "avatar_cache".into(), 103 | group: "group_avatar".into(), 104 | user: User::get_default() 105 | } 106 | ) 107 | } 108 | 109 | #[test] 110 | fn test_inner_drop() { 111 | let avatar = AvatarConfig::default(); 112 | 113 | let inner = avatar.into_inner(&ResourceDirectories::new().with_set_path(Some("gtk-qq"))); 114 | 115 | assert_eq!(inner.group, Path::new("gtk-qq\\avatars\\groups")); 116 | assert_eq!(inner.user, Path::new("gtk-qq\\avatars\\users")) 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /libs/resource-loader/src/configs/client.rs: -------------------------------------------------------------------------------- 1 | use derivative::Derivative; 2 | use ricq::version::{get_version, Version}; 3 | use serde::{Deserialize, Serialize}; 4 | 5 | #[derive(Debug, Serialize, Deserialize, Derivative)] 6 | #[derivative(Default)] 7 | pub struct ClientConfig { 8 | #[serde(default = "Default::default")] 9 | #[derivative(Default(value = "Default::default()"))] 10 | protocol: Protocol, 11 | 12 | #[serde(default = "default_seed")] 13 | #[derivative(Default(value = "default_seed()"))] 14 | device_seed: u64, 15 | } 16 | 17 | pub struct ClientInner { 18 | pub(crate) device_seed: u64, 19 | pub(crate) version: Version, 20 | } 21 | 22 | impl From for ClientInner { 23 | fn from(cfg: ClientConfig) -> Self { 24 | ClientInner { 25 | device_seed: cfg.device_seed, 26 | version: get_version(cfg.protocol.into()), 27 | } 28 | } 29 | } 30 | 31 | #[derive(Debug, Clone, Copy, Serialize, Derivative, Deserialize)] 32 | #[derivative(Default)] 33 | pub enum Protocol { 34 | #[serde(alias = "ipad")] 35 | IPad, 36 | #[serde(alias = "android-phone")] 37 | #[serde(alias = "android_phone")] 38 | AndroidPhone, 39 | #[serde(alias = "android-watch")] 40 | #[serde(alias = "android_watch")] 41 | AndroidWatch, 42 | #[derivative(Default)] 43 | #[serde(alias = "macos")] 44 | MacOS, 45 | #[serde(alias = "qi_dian")] 46 | #[serde(alias = "qi-dian")] 47 | QiDian, 48 | } 49 | 50 | impl From for ricq::version::Protocol { 51 | fn from(val: Protocol) -> Self { 52 | use ricq::version::Protocol::*; 53 | match val { 54 | Protocol::IPad => IPad, 55 | Protocol::AndroidPhone => AndroidPhone, 56 | Protocol::AndroidWatch => AndroidWatch, 57 | Protocol::MacOS => MacOS, 58 | Protocol::QiDian => QiDian, 59 | } 60 | } 61 | } 62 | 63 | fn default_seed() -> u64 { 64 | 1145141919810 65 | } 66 | -------------------------------------------------------------------------------- /libs/resource-loader/src/configs/config.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use derivative::Derivative; 4 | use serde::{Deserialize, Serialize}; 5 | 6 | use super::{ 7 | avatar::AvatarConfig, 8 | client::{ClientConfig, ClientInner}, 9 | local_db::DbConfig, 10 | temporary::{InnerTemporaryConfig, TemporaryConfig}, 11 | InnerAvatarConfig, InnerDbConfig, 12 | }; 13 | 14 | use crate::resource_directories::ResourceDirectories; 15 | 16 | #[derive(Debug, Serialize, Deserialize, Derivative)] 17 | #[derivative(Default)] 18 | pub struct Config { 19 | #[derivative(Default(value = "None"))] 20 | #[serde(default = "Default::default")] 21 | #[serde(alias = "res", alias = "resource")] 22 | #[serde(skip_serializing_if = "Option::is_none")] 23 | resource_root: Option, 24 | #[serde(default = "Default::default")] 25 | #[serde(alias = "temp", alias = "temporary")] 26 | temporary: TemporaryConfig, 27 | #[serde(default = "Default::default")] 28 | avatar: AvatarConfig, 29 | #[serde(default = "Default::default")] 30 | database: DbConfig, 31 | #[serde(default = "Default::default")] 32 | client: ClientConfig, 33 | } 34 | 35 | pub struct InnerConfig { 36 | pub(crate) temporary: InnerTemporaryConfig, 37 | pub(crate) avatar: InnerAvatarConfig, 38 | pub(crate) database: InnerDbConfig, 39 | pub(crate) client: ClientInner, 40 | } 41 | 42 | impl Config { 43 | pub(crate) fn into_inner(self, root: ResourceDirectories) -> InnerConfig { 44 | let root = root.with_set_path(self.resource_root); 45 | InnerConfig { 46 | avatar: self.avatar.into_inner(&root), 47 | database: self.database.into_inner(&root), 48 | temporary: self.temporary.into_inner(), 49 | client: self.client.into(), 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /libs/resource-loader/src/configs/local_db.rs: -------------------------------------------------------------------------------- 1 | use std::path::Path; 2 | 3 | use serde::{Deserialize, Serialize}; 4 | 5 | use crate::resource_directories::ResourceDirectories; 6 | 7 | use super::{free_path_ref, static_leak}; 8 | use derivative::Derivative; 9 | default_string! { 10 | BaseDir => "database" 11 | SqlData => "sql_db.db" 12 | } 13 | 14 | #[derive(Debug, Serialize, Deserialize, Derivative)] 15 | #[derivative(Default)] 16 | pub struct DbConfig { 17 | #[derivative(Default(value = "BaseDir::get_default()"))] 18 | #[serde(default = "BaseDir::get_default")] 19 | #[serde(alias = "base")] 20 | base_dir: String, 21 | 22 | #[derivative(Default(value = "SqlData::get_default()"))] 23 | #[serde(default = "SqlData::get_default")] 24 | #[serde(alias = "app_db")] 25 | sql_data: String, 26 | } 27 | 28 | pub(crate) struct InnerDbConfig { 29 | pub sql_data: &'static Path, 30 | } 31 | 32 | impl DbConfig { 33 | pub(crate) fn into_inner(self, base: &ResourceDirectories) -> InnerDbConfig { 34 | let base = base.get_data_local_home().join(&self.base_dir); 35 | 36 | let sql_data = static_leak(base.join(&self.sql_data).into_boxed_path()); 37 | 38 | InnerDbConfig { sql_data } 39 | } 40 | } 41 | 42 | impl Drop for InnerDbConfig { 43 | fn drop(&mut self) { 44 | free_path_ref(self.sql_data) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /libs/resource-loader/src/configs/mod.rs: -------------------------------------------------------------------------------- 1 | mod client; 2 | mod config; 3 | use std::path::Path; 4 | 5 | mod avatar; 6 | mod local_db; 7 | mod temporary; 8 | 9 | fn free_path_ref(path: &'static Path) { 10 | let box_path = unsafe { Box::from_raw(path as *const _ as *mut Path) }; 11 | logger!(trace "dropping Path=> {:?}", &box_path); 12 | drop(box_path) 13 | } 14 | 15 | fn static_leak(boxed: Box) -> &'static T { 16 | Box::leak(boxed) as &'static T 17 | } 18 | 19 | pub(crate) use avatar::InnerAvatarConfig; 20 | pub use config::{Config, InnerConfig}; 21 | pub(crate) use local_db::InnerDbConfig; 22 | -------------------------------------------------------------------------------- /libs/resource-loader/src/configs/temporary.rs: -------------------------------------------------------------------------------- 1 | use std::path::Path; 2 | 3 | use derivative::Derivative; 4 | use serde::{Deserialize, Serialize}; 5 | use tempfile::TempDir; 6 | 7 | use super::{free_path_ref, static_leak}; 8 | 9 | default_string! { 10 | CaptchaQrCode => "captcha_url.png" 11 | QrLoginQrCode => "qrcode_login.png" 12 | } 13 | 14 | #[derive(Debug, Clone, Serialize, Deserialize, Derivative)] 15 | #[derivative(Default)] 16 | pub struct TemporaryConfig { 17 | #[derivative(Default(value = "CaptchaQrCode::get_default()"))] 18 | #[serde(default = "CaptchaQrCode::get_default")] 19 | #[serde(alias = "captcha", alias = "captcha_url")] 20 | captcha_qrcode: String, 21 | 22 | #[derivative(Default(value = "QrLoginQrCode::get_default()"))] 23 | #[serde(default = "QrLoginQrCode::get_default")] 24 | #[serde(alias = "qr_login")] 25 | qrcode_login: String, 26 | } 27 | 28 | #[derive(Debug)] 29 | pub(crate) struct InnerTemporaryConfig { 30 | pub(crate) temp_dir: TempDir, 31 | pub(crate) captcha_file: &'static Path, 32 | pub(crate) qrcode_login: &'static Path, 33 | } 34 | 35 | impl TemporaryConfig { 36 | pub(crate) fn into_inner(self) -> InnerTemporaryConfig { 37 | let temp_dir = tempfile::tempdir().expect("Cannot Create Temporary Directory"); 38 | 39 | let captcha_file = static_leak(temp_dir.path().join(self.captcha_qrcode).into_boxed_path()); 40 | let qrcode_login = static_leak(temp_dir.path().join(self.qrcode_login).into_boxed_path()); 41 | InnerTemporaryConfig { 42 | temp_dir, 43 | captcha_file, 44 | qrcode_login, 45 | } 46 | } 47 | } 48 | 49 | impl Drop for InnerTemporaryConfig { 50 | fn drop(&mut self) { 51 | free_path_ref(self.captcha_file) 52 | } 53 | } 54 | 55 | #[cfg(test)] 56 | mod test { 57 | use super::TemporaryConfig; 58 | 59 | #[test] 60 | fn test_tmp_file() { 61 | let temp = TemporaryConfig::default(); 62 | 63 | let inner = temp.into_inner(); 64 | 65 | println!("{:?}", inner) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /libs/resource-loader/src/lib.rs: -------------------------------------------------------------------------------- 1 | mod ops; 2 | mod resource_directories; 3 | #[macro_use] 4 | mod utils; 5 | mod configs; 6 | mod static_data; 7 | 8 | pub use configs::Config; 9 | 10 | pub use static_data::ResourceConfig; 11 | 12 | pub use ops::{ 13 | avatar::{Group as AvatarGroup, User as AvatarUser}, 14 | client::{Device, Protocol}, 15 | database::SqlDataBase, 16 | temporary::{CaptchaQrCode, QrCodeLoginCode, TempDir}, 17 | AsyncCreatePath, AsyncLoadResource, DirAction, GetPath, SyncCreatePath, SyncLoadResource, 18 | }; 19 | -------------------------------------------------------------------------------- /libs/resource-loader/src/ops/avatar.rs: -------------------------------------------------------------------------------- 1 | use std::path::Path; 2 | 3 | use crate::{logger, static_data::load_cfg}; 4 | 5 | use super::GetPath; 6 | 7 | pub struct User; 8 | 9 | impl GetPath for User { 10 | fn get_path() -> &'static Path { 11 | let cfg = load_cfg(); 12 | logger!(info "loading `User Avatar` path"); 13 | cfg.avatar.user 14 | } 15 | } 16 | 17 | pub struct Group; 18 | 19 | impl GetPath for Group { 20 | fn get_path() -> &'static Path { 21 | let cfg = load_cfg(); 22 | logger!(info "loading `Group Avatar` path"); 23 | cfg.avatar.group 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /libs/resource-loader/src/ops/client.rs: -------------------------------------------------------------------------------- 1 | use std::convert::Infallible; 2 | 3 | use rand::{prelude::StdRng, SeedableRng}; 4 | 5 | use crate::{static_data::load_cfg, SyncLoadResource}; 6 | 7 | pub struct Device; 8 | 9 | impl SyncLoadResource for Device { 10 | type Args = (); 11 | 12 | type Error = Infallible; 13 | 14 | fn load_resource(_: Self::Args) -> Result { 15 | let seed = load_cfg().client.device_seed; 16 | Ok(ricq::device::Device::random_with_rng( 17 | &mut StdRng::seed_from_u64(seed), 18 | )) 19 | } 20 | } 21 | 22 | pub struct Protocol; 23 | 24 | impl SyncLoadResource for Protocol { 25 | type Args = (); 26 | 27 | type Error = Infallible; 28 | 29 | fn load_resource(_: Self::Args) -> Result { 30 | Ok(load_cfg().client.version.clone()) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /libs/resource-loader/src/ops/database.rs: -------------------------------------------------------------------------------- 1 | use std::path::Path; 2 | 3 | use crate::{logger, static_data::load_cfg}; 4 | 5 | use super::GetPath; 6 | 7 | pub struct SqlDataBase; 8 | 9 | impl GetPath for SqlDataBase { 10 | fn get_path() -> &'static Path { 11 | let cfg = load_cfg(); 12 | logger!(info "loading `Sql DataBase` path"); 13 | cfg.database.sql_data 14 | } 15 | 16 | fn path_for_create() -> Option<&'static Path> { 17 | ::get_path().parent() 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /libs/resource-loader/src/ops/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod avatar; 2 | pub mod client; 3 | pub mod database; 4 | pub mod temporary; 5 | use std::path::Path; 6 | 7 | pub trait GetPath { 8 | fn get_path() -> &'static Path; 9 | 10 | fn path_for_create() -> Option<&'static Path> { 11 | ::get_path().into() 12 | } 13 | } 14 | 15 | #[derive(Debug, PartialEq, Eq, Clone, Copy)] 16 | pub enum DirAction { 17 | CreateAll, 18 | None, 19 | } 20 | 21 | pub use sync_ops::{SyncCreatePath, SyncLoadResource}; 22 | 23 | mod sync_ops { 24 | use std::{any::type_name, fs::create_dir_all, io, path::Path}; 25 | 26 | use tap::Tap; 27 | 28 | use crate::{logger, DirAction, GetPath}; 29 | 30 | pub trait SyncCreatePath: GetPath { 31 | fn create_and_get_path() -> io::Result<&'static Path> { 32 | if let Some(path) = ::path_for_create() 33 | .tap(|path| logger!(debug "create path {:?} | {}", path, type_name::())) 34 | { 35 | create_dir_all(path)?; 36 | } 37 | Ok(::get_path() 38 | .tap(|path| logger!(info "get path: {:?} | {}", path, type_name::()))) 39 | } 40 | 41 | fn do_action_and_get_path(action: DirAction) -> io::Result<&'static Path> { 42 | logger!(debug "Sync Directory action : {:?} | {}", action, type_name::()); 43 | match action { 44 | DirAction::CreateAll => ::create_and_get_path(), 45 | DirAction::None => Ok(::get_path()), 46 | } 47 | } 48 | } 49 | 50 | impl SyncCreatePath for T where T: GetPath {} 51 | 52 | pub trait SyncLoadResource { 53 | type Args; 54 | type Error: std::error::Error; 55 | fn load_resource(args: Self::Args) -> Result; 56 | } 57 | } 58 | 59 | pub use async_ops::{AsyncCreatePath, AsyncLoadResource}; 60 | 61 | mod async_ops { 62 | use std::{any::type_name, future::Future, io, path::Path, pin::Pin}; 63 | 64 | use tokio::fs::create_dir_all; 65 | 66 | use crate::{logger, DirAction, GetPath}; 67 | 68 | pub trait AsyncCreatePath: GetPath { 69 | fn create_and_get_path_async( 70 | ) -> Pin> + Send + Sync>> { 71 | let create_path = ::path_for_create(); 72 | logger!(debug "create path {:?} | {}", create_path, type_name::()); 73 | let path = ::get_path(); 74 | logger!(info "get path: {:?} | {}", path, type_name::()); 75 | Box::pin(async move { 76 | if let Some(path) = create_path { 77 | create_dir_all(path).await?; 78 | } 79 | Ok(path) 80 | }) 81 | } 82 | 83 | fn do_action_and_get_path_async( 84 | action: DirAction, 85 | ) -> Pin> + Send + Sync>> { 86 | logger!(debug "Async Directory action : {:?} | {}", action, type_name::()); 87 | Box::pin(async move { 88 | match action { 89 | DirAction::CreateAll => { 90 | ::create_and_get_path_async().await 91 | } 92 | DirAction::None => Ok(::get_path()), 93 | } 94 | }) 95 | } 96 | } 97 | 98 | impl AsyncCreatePath for T where T: GetPath {} 99 | 100 | pub trait AsyncLoadResource { 101 | type Fut: Future> + Send + Sync; 102 | type Error: std::error::Error; 103 | type Args; 104 | 105 | fn load_resource_async(args: Self::Args) -> Self::Fut; 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /libs/resource-loader/src/ops/temporary.rs: -------------------------------------------------------------------------------- 1 | use std::path::Path; 2 | 3 | use tap::Tap; 4 | 5 | use crate::{logger, static_data::load_cfg}; 6 | 7 | use super::GetPath; 8 | 9 | pub struct TempDir; 10 | 11 | impl GetPath for TempDir { 12 | fn get_path() -> &'static Path { 13 | load_cfg() 14 | .tap(|_| logger!(info "loading `Temporary Directory` path")) 15 | .temporary 16 | .temp_dir 17 | .path() 18 | } 19 | 20 | fn path_for_create() -> Option<&'static Path> { 21 | None 22 | } 23 | } 24 | 25 | pub struct CaptchaQrCode; 26 | 27 | impl GetPath for CaptchaQrCode { 28 | fn get_path() -> &'static Path { 29 | load_cfg() 30 | .tap(|_| logger!(info "loading `Captcha QrCode Picture` path")) 31 | .temporary 32 | .captcha_file 33 | } 34 | 35 | fn path_for_create() -> Option<&'static Path> { 36 | None 37 | } 38 | } 39 | 40 | pub struct QrCodeLoginCode; 41 | 42 | impl GetPath for QrCodeLoginCode { 43 | fn get_path() -> &'static Path { 44 | load_cfg() 45 | .tap(|_| logger!(info "loading `QrCode Login QrCode Picture` path")) 46 | .temporary 47 | .qrcode_login 48 | } 49 | fn path_for_create() -> Option<&'static Path> { 50 | None 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /libs/resource-loader/src/resource_directories.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | io, 3 | path::{Path, PathBuf}, 4 | }; 5 | 6 | use tap::Tap; 7 | 8 | use crate::logger; 9 | 10 | pub struct ResourceDirectories { 11 | project_dir: directories::ProjectDirs, 12 | // moving 13 | data_move: Option, 14 | } 15 | 16 | impl ResourceDirectories { 17 | pub fn with_set_path(mut self, path: Option>) -> Self { 18 | self.data_move = path.map(|path| path.as_ref().to_path_buf()); 19 | self 20 | } 21 | 22 | pub fn new() -> Self { 23 | let project_dir = directories::ProjectDirs::from("ricq", "gtk-qq", "gtk-qq") 24 | .expect("User Home directory not exist") 25 | .tap(|path| logger!(info "config local directory : {:?}", path.config_dir())); 26 | 27 | Self { 28 | project_dir, 29 | data_move: None, 30 | } 31 | } 32 | } 33 | #[allow(dead_code)] 34 | impl ResourceDirectories { 35 | pub fn get_config_path(&self) -> PathBuf { 36 | self.project_dir.config_dir().join("config.toml") 37 | } 38 | 39 | pub fn place_config_path(&self) -> io::Result { 40 | let config_dir = self.project_dir.config_dir(); 41 | if !config_dir.exists() { 42 | logger!(info "config location directory need create"); 43 | std::fs::create_dir_all(&config_dir)?; 44 | } 45 | Ok(self.get_config_path()) 46 | } 47 | 48 | pub fn get_cache_home(&self) -> PathBuf { 49 | match self.data_move { 50 | Some(ref path) => path.join("cache"), 51 | None => self.project_dir.cache_dir().to_path_buf(), 52 | } 53 | } 54 | pub fn get_state_home(&self) -> Option { 55 | match self.data_move { 56 | Some(ref path) => Some(path.join("state")), 57 | None => self.project_dir.state_dir().map(Path::to_path_buf), 58 | } 59 | } 60 | pub fn get_data_home(&self) -> PathBuf { 61 | match self.data_move { 62 | Some(ref path) => path.join("data"), 63 | None => self.project_dir.data_dir().to_path_buf(), 64 | } 65 | } 66 | pub fn get_data_local_home(&self) -> PathBuf { 67 | match self.data_move { 68 | Some(ref path) => path.join("data_local"), 69 | None => self.project_dir.data_local_dir().to_path_buf(), 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /libs/resource-loader/src/static_data.rs: -------------------------------------------------------------------------------- 1 | //! static data 2 | 3 | use std::{io, path::PathBuf}; 4 | 5 | use once_cell::sync::OnceCell; 6 | 7 | use crate::{ 8 | configs::{Config, InnerConfig}, 9 | resource_directories::ResourceDirectories, 10 | }; 11 | 12 | static CONFIGURATION: OnceCell = OnceCell::new(); 13 | 14 | pub struct ResourceConfig; 15 | 16 | pub(crate) fn load_cfg() -> &'static InnerConfig { 17 | logger!(info "Loading Config"); 18 | CONFIGURATION.get_or_init(|| { 19 | logger!(warn "Config not set. Using Default Config"); 20 | Config::default().into_inner(ResourceDirectories::new()) 21 | }) 22 | } 23 | 24 | fn create_and_get_config_path() -> io::Result { 25 | ResourceDirectories::new().place_config_path() 26 | } 27 | 28 | fn get_config_path() -> PathBuf { 29 | ResourceDirectories::new().get_config_path() 30 | } 31 | 32 | impl ResourceConfig { 33 | pub fn set_config(cfg: Config) { 34 | logger!(info "setting config"); 35 | if CONFIGURATION 36 | .set(cfg.into_inner(ResourceDirectories::new())) 37 | .is_err() 38 | { 39 | panic!("Config had been set") 40 | } 41 | } 42 | 43 | pub fn load_or_create_default() -> io::Result<()> { 44 | let cfg_file = get_config_path(); 45 | if !cfg_file.exists() || !cfg_file.is_file() { 46 | logger!(warn "Config file not exist, create file using default config"); 47 | Self::save_config(Default::default())?; 48 | Self::set_config(Default::default()); 49 | Ok(()) 50 | } else { 51 | Self::load_from_file() 52 | } 53 | } 54 | 55 | pub fn load_from_file() -> io::Result<()> { 56 | logger!(info "loading config from file"); 57 | let config_file = create_and_get_config_path()?; 58 | logger!(info "reading file stream into vector"); 59 | let file = std::fs::read(config_file)?; 60 | logger!(info "parse file stream as `TOML` file | file size : {} bytes", file.len()); 61 | let cfg = toml::from_slice::(&file).expect("Bad Config File Format"); 62 | logger!(info "setting config loading from file"); 63 | Self::set_config(cfg); 64 | Ok(()) 65 | } 66 | 67 | pub fn save_config(cfg: Config) -> io::Result<()> { 68 | let config_file = create_and_get_config_path()?; 69 | 70 | let cfg = toml::to_string_pretty(&cfg).expect("Serde Config Error"); 71 | logger!(info "writing config into file {:?}", config_file); 72 | std::fs::write(config_file, cfg)?; 73 | 74 | Ok(()) 75 | } 76 | } 77 | #[cfg(test)] 78 | mod test { 79 | use crate::{ops::avatar, Config, GetPath}; 80 | 81 | use super::ResourceConfig; 82 | 83 | #[test] 84 | fn test_load_cfg() { 85 | let _cfg = super::load_cfg(); 86 | } 87 | 88 | #[test] 89 | fn generate_conf() { 90 | let cfg = Config::default(); 91 | 92 | let cfg = toml::to_string_pretty(&cfg).unwrap(); 93 | 94 | println!("{cfg}") 95 | } 96 | 97 | #[test] 98 | fn save_cfg() { 99 | let cfg = Config::default(); 100 | 101 | ResourceConfig::save_config(cfg).unwrap(); 102 | } 103 | #[test] 104 | fn load_cfg() { 105 | ResourceConfig::load_from_file().unwrap(); 106 | 107 | let avatar_group = avatar::Group::get_path(); 108 | println!("{avatar_group:?}") 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /libs/resource-loader/src/utils.rs: -------------------------------------------------------------------------------- 1 | macro_rules! default_string { 2 | ($name:ident=> $default:literal) => { 3 | struct $name; 4 | 5 | impl $name { 6 | fn get_default() -> String { 7 | String::from($default) 8 | } 9 | } 10 | }; 11 | 12 | { 13 | $($name:ident=> $default:literal)* 14 | }=>{ 15 | $( 16 | default_string!($name => $default); 17 | )* 18 | } 19 | } 20 | 21 | #[macro_export(crate)] 22 | macro_rules! logger { 23 | (info $l:literal $(, $v:expr)*) => { 24 | { 25 | #[cfg(feature = "logger")] 26 | log::info!($l, $($v),*); 27 | #[cfg(not(feature = "logger"))] 28 | println!("[INFO] {}",format!($l, $($v),*)); 29 | } 30 | }; 31 | (debug $l:literal $(, $v:expr)*) => { 32 | { 33 | #[cfg(feature = "logger")] 34 | log::debug!($l , $($v),*); 35 | #[cfg(not(feature = "logger"))] 36 | println!("[DEBUG] {}",format!($l, $($v),*)); 37 | } 38 | }; 39 | (warn $l:literal $(, $v:expr)*) => { 40 | { 41 | #[cfg(feature = "logger")] 42 | log::warn!($l, $($v),*); 43 | #[cfg(not(feature = "logger"))] 44 | println!("[WARN] {}",format!($l, $($v),*)); 45 | } 46 | }; 47 | (trace $l:literal $(, $v:expr)*) => { 48 | { 49 | #[cfg(feature = "logger")] 50 | log::trace!($l, $($v),*); 51 | #[cfg(not(feature = "logger"))] 52 | println!("[TRACE] {}",format!($l, $($v),*)); 53 | } 54 | }; 55 | (error $l:literal $(, $v:expr)*) => { 56 | { 57 | #[cfg(feature = "logger")] 58 | log::error!($l, $($v),*); 59 | #[cfg(not(feature = "logger"))] 60 | eprintln!("[ERROR] {}",format!($l, $($v),*)); 61 | } 62 | }; 63 | } 64 | -------------------------------------------------------------------------------- /libs/widgets/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "widgets" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies.relm4] 7 | git = "https://github.com/Relm4/Relm4.git" 8 | # next, 2022/05/30 9 | rev = "144f48319ffd7a889f28853df00e802cfc97dc26" 10 | features = ["macros", "libadwaita"] 11 | 12 | [dependencies] 13 | typed-builder = "0.10" 14 | 15 | [dependencies.ricq] 16 | git = "https://github.com/lz1998/ricq.git" 17 | # v0.1.17, master, 2022/09/07 18 | rev = "56620d755f35f7b6ade52991be62360b3377547c" 19 | -------------------------------------------------------------------------------- /libs/widgets/src/lib.rs: -------------------------------------------------------------------------------- 1 | #![allow(clippy::needless_borrow)] 2 | 3 | pub mod link_copier; 4 | pub mod pwd_login; 5 | pub mod qrcode_login; 6 | -------------------------------------------------------------------------------- /libs/widgets/src/link_copier/component.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use relm4::{gtk, ComponentParts}; 4 | 5 | use gtk::prelude::*; 6 | 7 | use super::{widgets::LinkCopierWidgets, Input, Output, Payload, State}; 8 | 9 | #[derive(Debug)] 10 | pub struct LinkCopierModel { 11 | link: String, 12 | label: Option, 13 | state: State, 14 | } 15 | 16 | impl relm4::SimpleComponent for LinkCopierModel { 17 | type Input = Input; 18 | 19 | type Output = Output; 20 | 21 | type InitParams = Payload; 22 | 23 | type Root = gtk::Box; 24 | 25 | type Widgets = LinkCopierWidgets; 26 | 27 | fn init_root() -> Self::Root { 28 | gtk::Box::builder() 29 | .css_name("link-copier") 30 | .orientation(gtk::Orientation::Horizontal) 31 | .halign(gtk::Align::Center) 32 | .valign(gtk::Align::Center) 33 | .build() 34 | } 35 | 36 | fn init( 37 | params: Self::InitParams, 38 | root: &Self::Root, 39 | sender: &relm4::ComponentSender, 40 | ) -> relm4::ComponentParts { 41 | let ty = params.ty; 42 | let widget = Self::Widgets::new(¶ms, Arc::clone(sender)); 43 | 44 | match ty { 45 | State::Both => { 46 | root.append(&widget.link_btn); 47 | root.append(&widget.copy_btn); 48 | } 49 | State::LinkOnly => root.append(&widget.link_btn), 50 | State::BtnOnly => root.append(&widget.copy_btn), 51 | }; 52 | 53 | let model = LinkCopierModel { 54 | link: params.url, 55 | label: params.label, 56 | state: ty, 57 | }; 58 | 59 | ComponentParts { 60 | model, 61 | widgets: widget, 62 | } 63 | } 64 | 65 | fn update(&mut self, message: Self::Input, _sender: &relm4::ComponentSender) { 66 | match message { 67 | Input::Link(url) => self.link = url.into_owned(), 68 | Input::Label(label) => { 69 | self.label.replace(label.into_owned()); 70 | } 71 | Input::State(s) => self.state = s, 72 | } 73 | } 74 | 75 | fn update_view(&self, widgets: &mut Self::Widgets, _sender: &relm4::ComponentSender) { 76 | let label = <&Option as Into>>::into(&self.label); 77 | let label = label.unwrap_or(&self.link).as_str(); 78 | 79 | widgets.link_btn.set_uri(&self.link); 80 | widgets.link_btn.set_label(label); 81 | 82 | match self.state { 83 | State::Both => { 84 | widgets.copy_btn.set_visible(true); 85 | widgets.link_btn.set_visible(true); 86 | } 87 | State::LinkOnly => { 88 | widgets.copy_btn.set_visible(false); 89 | widgets.link_btn.set_visible(true); 90 | } 91 | State::BtnOnly => { 92 | widgets.copy_btn.set_visible(true); 93 | widgets.link_btn.set_visible(false); 94 | } 95 | } 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /libs/widgets/src/link_copier/mod.rs: -------------------------------------------------------------------------------- 1 | mod component; 2 | mod payloads; 3 | mod widgets; 4 | 5 | pub use component::LinkCopierModel; 6 | pub use payloads::{Input, Output, Payload, State}; 7 | pub use widgets::LinkCopierWidgets; 8 | 9 | pub type LinkCopier = relm4::component::Controller; 10 | -------------------------------------------------------------------------------- /libs/widgets/src/link_copier/payloads.rs: -------------------------------------------------------------------------------- 1 | use std::borrow::Cow; 2 | 3 | #[derive(Debug, Clone, Copy)] 4 | 5 | pub enum State { 6 | Both, 7 | LinkOnly, 8 | BtnOnly, 9 | } 10 | 11 | pub enum Input { 12 | Link(Cow<'static, String>), 13 | Label(Cow<'static, String>), 14 | State(State), 15 | } 16 | 17 | pub enum Output { 18 | LinkCopied, 19 | } 20 | 21 | #[derive(Debug, typed_builder::TypedBuilder)] 22 | pub struct Payload { 23 | pub(super) url: String, 24 | #[builder(default, setter(strip_option))] 25 | pub(super) label: Option, 26 | #[builder(default=State::Both)] 27 | pub(super) ty: State, 28 | } 29 | -------------------------------------------------------------------------------- /libs/widgets/src/link_copier/widgets.rs: -------------------------------------------------------------------------------- 1 | use relm4::{gtk, ComponentSender}; 2 | 3 | use gtk::prelude::*; 4 | use gtk::{Button, LinkButton}; 5 | 6 | use super::{payloads::Payload, LinkCopierModel, Output}; 7 | 8 | #[derive(Debug, typed_builder::TypedBuilder)] 9 | pub struct LinkCopierWidgets { 10 | pub(super) link_btn: LinkButton, 11 | pub(super) copy_btn: Button, 12 | } 13 | 14 | impl LinkCopierWidgets { 15 | pub(super) fn new(cfg: &Payload, sender: ComponentSender) -> Self { 16 | let link_btn = Self::create_link_btn(cfg.label.clone(), cfg.url.clone()); 17 | let copy_btn = Self::create_copy_btn(cfg.url.clone(), sender); 18 | 19 | Self { link_btn, copy_btn } 20 | } 21 | 22 | fn create_link_btn(label: Option, url: String) -> LinkButton { 23 | let label = label.unwrap_or_else(|| url.clone()); 24 | LinkButton::builder().uri(&url).label(&label).build() 25 | } 26 | 27 | fn create_copy_btn(url: String, sender: ComponentSender) -> Button { 28 | let button = Button::builder().label("Copy Link").build(); 29 | button.connect_clicked(move |btn| { 30 | // Paste the url to clipboard 31 | let clipboard = btn.clipboard(); 32 | clipboard.set_text(&url); 33 | 34 | sender.output(Output::LinkCopied); 35 | }); 36 | button 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /libs/widgets/src/pwd_login/component.rs: -------------------------------------------------------------------------------- 1 | use relm4::gtk::{self, gdk::Paintable, traits::EditableExt}; 2 | 3 | use super::{ 4 | payloads::{Input, Output, Payload, PwdEntry, State}, 5 | widgets::PwdLoginWidget, 6 | }; 7 | 8 | #[derive(Debug)] 9 | pub struct PasswordLoginModel { 10 | account_changed: bool, 11 | account_state: State, 12 | account: Option, 13 | password: PwdEntry, 14 | avatar: Option, 15 | } 16 | 17 | impl relm4::SimpleComponent for PasswordLoginModel { 18 | type Input = Input; 19 | 20 | type Output = Output; 21 | 22 | type InitParams = Payload; 23 | 24 | type Root = gtk::Box; 25 | 26 | type Widgets = PwdLoginWidget; 27 | 28 | fn init_root() -> Self::Root { 29 | gtk::Box::builder() 30 | .orientation(gtk::Orientation::Horizontal) 31 | .halign(gtk::Align::Center) 32 | .valign(gtk::Align::Center) 33 | .vexpand(true) 34 | .spacing(32) 35 | .build() 36 | } 37 | 38 | fn init( 39 | params: Self::InitParams, 40 | root: &Self::Root, 41 | sender: &relm4::ComponentSender, 42 | ) -> relm4::ComponentParts { 43 | sender.output(Output::EnableLogin( 44 | params.account.is_some() && params.token.is_some(), 45 | )); 46 | if params.auto_login { 47 | sender.input(Input::Login); 48 | } 49 | 50 | let widgets = 51 | PwdLoginWidget::new(root, ¶ms, sender.input_sender(), sender.output_sender()); 52 | 53 | let pwd = match params.token { 54 | Some(token) => PwdEntry::Token(token), 55 | None => PwdEntry::None, 56 | }; 57 | 58 | let model = Self { 59 | account: params.account, 60 | password: pwd, 61 | account_changed: false, 62 | account_state: State::NoChange, 63 | avatar: params.avatar, 64 | }; 65 | 66 | relm4::ComponentParts { model, widgets } 67 | } 68 | 69 | fn update(&mut self, message: Self::Input, sender: &relm4::ComponentSender) { 70 | match message { 71 | Input::Account(ac) => { 72 | if let State::NoChange = self.account_state { 73 | if let Ok(uin) = ac.parse::() { 74 | self.account.replace(uin); 75 | self.account_changed = true; 76 | } else if ac.is_empty() { 77 | self.account = None; 78 | self.account_changed = true; 79 | } else { 80 | self.account_state = State::Update; 81 | } 82 | } else { 83 | self.account_state = State::NoChange; 84 | } 85 | } 86 | Input::Password(pwd) => { 87 | if !pwd.is_empty() { 88 | let n = match self.password { 89 | PwdEntry::None => PwdEntry::Password(pwd), 90 | PwdEntry::Token(_) => PwdEntry::None, 91 | PwdEntry::Password(_) => PwdEntry::Password(pwd), 92 | }; 93 | self.password = n; 94 | } else { 95 | self.password = PwdEntry::None 96 | } 97 | } 98 | Input::Login => match (self.password.clone(), self.account) { 99 | (PwdEntry::Password(pwd), Some(account)) => { 100 | sender.output(Output::Login { account, pwd }) 101 | } 102 | (PwdEntry::Token(token), _) => sender.output(Output::TokenLogin(token)), 103 | (_, _) => sender.output(Output::EnableLogin(false)), 104 | }, 105 | Input::Avatar(pic) => self.avatar = pic, 106 | } 107 | } 108 | 109 | fn update_view(&self, widgets: &mut Self::Widgets, sender: &relm4::ComponentSender) { 110 | if let State::Update = self.account_state { 111 | widgets.account.set_text( 112 | &self 113 | .account 114 | .map(|a| a.to_string()) 115 | .unwrap_or_else(String::new), 116 | ); 117 | } 118 | 119 | if let PwdEntry::None = self.password { 120 | widgets.pwd.set_text(""); 121 | } 122 | 123 | sender.output(Output::EnableLogin( 124 | self.account.is_some() && self.password.is_some(), 125 | )); 126 | 127 | if self.account_changed { 128 | widgets 129 | .avatar 130 | .set_custom_image(Option::<&'static Paintable>::None); 131 | } 132 | 133 | widgets 134 | .avatar 135 | .set_custom_image(Into::>::into(&self.avatar)); 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /libs/widgets/src/pwd_login/mod.rs: -------------------------------------------------------------------------------- 1 | mod component; 2 | mod payloads; 3 | mod widgets; 4 | 5 | pub use component::PasswordLoginModel; 6 | pub use payloads::{Input, Output, Payload}; 7 | 8 | pub use widgets::PwdLoginWidget; 9 | 10 | pub type PasswordLogin = relm4::component::Controller; 11 | -------------------------------------------------------------------------------- /libs/widgets/src/pwd_login/payloads.rs: -------------------------------------------------------------------------------- 1 | use relm4::gtk::gdk::Paintable; 2 | use ricq::client::Token; 3 | 4 | pub enum Input { 5 | Account(String), 6 | Password(String), 7 | Login, 8 | Avatar(Option), 9 | } 10 | 11 | pub enum Output { 12 | Login { account: i64, pwd: String }, 13 | TokenLogin(Token), 14 | EnableLogin(bool), 15 | RememberPwd(bool), 16 | AutoLogin(bool), 17 | } 18 | 19 | #[derive(Debug)] 20 | pub(super) enum State { 21 | NoChange, 22 | Update, 23 | } 24 | 25 | #[derive(Debug, Clone)] 26 | pub(super) enum PwdEntry { 27 | None, 28 | Token(Token), 29 | Password(String), 30 | } 31 | 32 | #[derive(Debug, Default)] 33 | pub struct Payload { 34 | pub account: Option, 35 | pub token: Option, 36 | pub avatar: Option, 37 | pub remember_pwd: bool, 38 | pub auto_login: bool, 39 | } 40 | 41 | impl PwdEntry { 42 | pub(super) fn is_some(&self) -> bool { 43 | !matches!(self, PwdEntry::None) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /libs/widgets/src/pwd_login/widgets.rs: -------------------------------------------------------------------------------- 1 | use relm4::{adw, gtk, Sender}; 2 | 3 | use adw::{prelude::*, ActionRow, Avatar, PreferencesGroup}; 4 | use gtk::{Align, Box, CheckButton, Entry, EntryBuffer, Orientation, PasswordEntry}; 5 | 6 | use super::{ 7 | payloads::{Input, Payload}, 8 | Output, 9 | }; 10 | 11 | #[derive(Debug)] 12 | pub struct PwdLoginWidget { 13 | pub(super) avatar: Avatar, 14 | pub(super) account: Entry, 15 | pub(super) pwd: PasswordEntry, 16 | } 17 | 18 | impl PwdLoginWidget { 19 | pub(super) fn new( 20 | root: &Box, 21 | payload: &Payload, 22 | sender: &Sender, 23 | output: &Sender, 24 | ) -> Self { 25 | relm4::view! { 26 | #[name = "input_area"] 27 | Box { 28 | set_orientation: Orientation::Vertical, 29 | set_halign: Align::Center, 30 | set_valign: Align::Center, 31 | set_vexpand: true, 32 | set_spacing: 12, 33 | PreferencesGroup { 34 | add = &ActionRow { 35 | set_title: "Account ", 36 | set_focusable: false, 37 | add_suffix: account = &Entry { 38 | set_valign: Align::Center, 39 | set_placeholder_text: Some("QQ account"), 40 | connect_changed[sender] => move |entry|{ 41 | sender.send(Input::Account(entry.buffer().text())) 42 | }, 43 | } 44 | }, 45 | add = &ActionRow { 46 | set_title: "Password", 47 | set_focusable: false, 48 | add_suffix: pwd = &PasswordEntry { 49 | set_valign: Align::Center, 50 | set_show_peek_icon: true, 51 | set_activates_default: true, 52 | set_placeholder_text: Some("QQ password"), 53 | connect_changed[sender] => move |entry|{ 54 | sender.send(Input::Password(entry.text().to_string())) 55 | }, 56 | connect_activate[sender] => move |_|{ 57 | sender.send(Input::Login) 58 | }, 59 | } 60 | } 61 | }, 62 | #[name = "edit_box"] 63 | Box { 64 | set_orientation: Orientation::Horizontal, 65 | set_valign: Align::Center, 66 | set_halign: Align::Center, 67 | set_spacing: 8, 68 | append = &CheckButton { 69 | set_active: payload.remember_pwd, 70 | set_label: Some("Remember Password"), 71 | connect_toggled[output] => move |this|{ 72 | output.send(Output::RememberPwd(this.is_active())); 73 | }, 74 | }, 75 | append = &CheckButton { 76 | set_label: Some("Auto Login"), 77 | set_sensitive: false, 78 | set_active: payload.auto_login, 79 | connect_toggled[output] => move |this|{ 80 | output.send(Output::AutoLogin(this.is_active())); 81 | }, 82 | } 83 | } 84 | } 85 | } 86 | 87 | relm4::view! { 88 | #[name = "avatar"] 89 | Avatar { 90 | set_size: 96, 91 | } 92 | } 93 | 94 | if let Some(ref paintable) = payload.avatar { 95 | avatar.set_custom_image(Some(paintable)); 96 | } 97 | 98 | if let Some(uin) = payload.account { 99 | let buf = EntryBuffer::new(Some(&uin.to_string())); 100 | account.set_buffer(&buf); 101 | } 102 | 103 | if payload.token.is_some() { 104 | pwd.set_text("0123456789"); 105 | } 106 | 107 | root.append(&avatar); 108 | root.append(&input_area); 109 | 110 | Self { 111 | avatar, 112 | account, 113 | pwd, 114 | } 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /libs/widgets/src/qrcode_login/component.rs: -------------------------------------------------------------------------------- 1 | use std::path::Path; 2 | 3 | use relm4::{ 4 | gtk::{self, gdk_pixbuf::Pixbuf}, 5 | ComponentParts, ComponentSender, 6 | }; 7 | 8 | use super::{payloads, widgets::QrCodeLoginWidgets}; 9 | 10 | #[derive(Debug)] 11 | pub struct QrCodeLoginModel { 12 | picture: Option, 13 | temp_path: &'static Path, 14 | } 15 | 16 | impl relm4::SimpleComponent for QrCodeLoginModel { 17 | type Input = payloads::Input; 18 | 19 | type Output = payloads::Output; 20 | 21 | type InitParams = payloads::PayLoad; 22 | 23 | type Root = gtk::Box; 24 | 25 | type Widgets = QrCodeLoginWidgets; 26 | 27 | fn init_root() -> Self::Root { 28 | gtk::Box::builder() 29 | .orientation(gtk::Orientation::Vertical) 30 | .width_request(400) 31 | .height_request(300) 32 | .spacing(5) 33 | .build() 34 | } 35 | 36 | fn init( 37 | params: Self::InitParams, 38 | root: &Self::Root, 39 | _: &ComponentSender, 40 | ) -> ComponentParts { 41 | let widget = QrCodeLoginWidgets::new(root); 42 | ComponentParts { 43 | model: Self { 44 | picture: None, 45 | temp_path: params.temp_img_path, 46 | }, 47 | widgets: widget, 48 | } 49 | } 50 | 51 | fn update(&mut self, message: Self::Input, _: &ComponentSender) { 52 | match message { 53 | payloads::Input::UpdateQrCode => { 54 | self.picture 55 | .replace(Pixbuf::from_file(self.temp_path).expect("Error to load QrCode")); 56 | } 57 | } 58 | } 59 | 60 | fn update_view(&self, widgets: &mut Self::Widgets, _: &ComponentSender) { 61 | if let Some(pic) = &self.picture { 62 | widgets.qr_code.set_pixbuf(Some(pic)); 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /libs/widgets/src/qrcode_login/mod.rs: -------------------------------------------------------------------------------- 1 | mod component; 2 | mod payloads; 3 | mod widgets; 4 | 5 | pub use component::QrCodeLoginModel; 6 | pub use payloads::{Input, Output, PayLoad}; 7 | pub use widgets::QrCodeLoginWidgets; 8 | 9 | pub type QrCodeLogin = relm4::component::Controller; 10 | -------------------------------------------------------------------------------- /libs/widgets/src/qrcode_login/payloads.rs: -------------------------------------------------------------------------------- 1 | use std::path::Path; 2 | 3 | pub enum Input { 4 | UpdateQrCode, 5 | } 6 | 7 | pub enum Output {} 8 | 9 | pub struct PayLoad { 10 | pub temp_img_path: &'static Path, 11 | } 12 | -------------------------------------------------------------------------------- /libs/widgets/src/qrcode_login/widgets.rs: -------------------------------------------------------------------------------- 1 | use relm4::gtk::{prelude::*, Align, Box, Label, Orientation, Picture}; 2 | 3 | #[derive(Debug)] 4 | pub struct QrCodeLoginWidgets { 5 | pub(super) qr_code: Picture, 6 | } 7 | 8 | impl QrCodeLoginWidgets { 9 | pub(super) fn new(root: &Box) -> Self { 10 | relm4::view! { 11 | #[name = "wrapper"] 12 | Box { 13 | set_halign: Align::Center, 14 | set_valign: Align::Center, 15 | set_orientation: Orientation::Vertical, 16 | #[name = "qr_code"] 17 | Picture { 18 | set_halign: Align::Center, 19 | set_valign: Align::Start, 20 | }, 21 | Label { 22 | set_label: "Scan the QrCode to login", 23 | set_halign: Align::Center, 24 | set_valign: Align::Center, 25 | set_margin_top: 18 26 | } 27 | } 28 | } 29 | 30 | root.append(&wrapper); 31 | 32 | Self { qr_code } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /meson.build: -------------------------------------------------------------------------------- 1 | project( 2 | 'gtk-qq', 3 | 'rust', 4 | version : '0.2.0', 5 | default_options : ['warning_level=3']) 6 | 7 | prefix = get_option('prefix') 8 | 9 | application_id = 'indi.lomirus.gtk-qq' 10 | 11 | subdir('src') 12 | subdir('assets') 13 | -------------------------------------------------------------------------------- /src/actions.rs: -------------------------------------------------------------------------------- 1 | use relm4::actions::{RelmAction, RelmActionGroup}; 2 | use relm4::{adw, gtk}; 3 | 4 | use adw::ApplicationWindow; 5 | use gtk::{gio::SimpleActionGroup, prelude::*, License}; 6 | 7 | use crate::config::{APPLICATION_ID, VERSION}; 8 | 9 | relm4::new_action_group!(pub WindowActionGroup, "menu"); 10 | relm4::new_stateless_action!(pub ShortcutsAction, WindowActionGroup, "shortcuts"); 11 | relm4::new_stateless_action!(pub AboutAction, WindowActionGroup, "about"); 12 | 13 | fn show_shortcuts() { 14 | println!("Keyboard Shortcuts"); 15 | } 16 | 17 | fn show_about(window: &ApplicationWindow) { 18 | let dialog = gtk::AboutDialog::builder() 19 | .comments("Unofficial Linux QQ client, based on GTK4 and libadwaita, developed with Rust and Relm4.") 20 | .icon_name(APPLICATION_ID) 21 | .transient_for(window) 22 | .modal(true) 23 | .program_name("Gtk QQ") 24 | .version(VERSION) 25 | .website_label("Github") 26 | .website("https://github.com/lomirus/gtk-qq") 27 | .authors(vec!["Lomirus".into()]) 28 | .license_type(License::Agpl30) 29 | .build(); 30 | 31 | dialog.present(); 32 | } 33 | 34 | pub fn create_gactions(window: ApplicationWindow) -> SimpleActionGroup { 35 | let shortcuts_action: RelmAction = 36 | RelmAction::new_stateless(|_| show_shortcuts()); 37 | let about_action: RelmAction = 38 | RelmAction::new_stateless(move |_| show_about(&window.clone())); 39 | 40 | let group: RelmActionGroup = RelmActionGroup::new(); 41 | group.add_action(shortcuts_action); 42 | group.add_action(about_action); 43 | 44 | group.into_action_group() 45 | } 46 | -------------------------------------------------------------------------------- /src/app/login/captcha/mod.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use relm4::gtk::glib::clone; 4 | use relm4::gtk::Align; 5 | use relm4::{adw, gtk, Component, ComponentController, ComponentSender, WidgetPlus}; 6 | 7 | use adw::{HeaderBar, Window}; 8 | use gtk::prelude::*; 9 | use gtk::{Box, Button, Entry, Label, Orientation, Picture}; 10 | 11 | use resource_loader::{CaptchaQrCode, GetPath}; 12 | use ricq::Client; 13 | use tokio::task; 14 | use widgets::link_copier::{self, LinkCopierModel}; 15 | 16 | use super::LoginPageMsg; 17 | 18 | #[derive(Clone)] 19 | pub struct CaptchaModel { 20 | pub(crate) client: Arc, 21 | pub(crate) ticket: String, 22 | } 23 | 24 | pub struct CaptchaWidgets { 25 | window: Window, 26 | } 27 | 28 | pub enum Input { 29 | UpdateTicket(String), 30 | Submit, 31 | CloseWindow, 32 | } 33 | 34 | pub struct PayLoad { 35 | pub(crate) client: Arc, 36 | pub(crate) window: Window, 37 | pub(crate) verify_url: String, 38 | } 39 | 40 | impl Component for CaptchaModel { 41 | type Input = Input; 42 | type Output = LoginPageMsg; 43 | type InitParams = PayLoad; 44 | type Root = Box; 45 | type Widgets = CaptchaWidgets; 46 | type CommandOutput = (); 47 | 48 | fn init_root() -> Self::Root { 49 | relm4::view! { 50 | root = Box { 51 | set_orientation: Orientation::Vertical, 52 | HeaderBar { 53 | set_title_widget = Some(&Label) { 54 | set_label: "Captcha Verify Introduction" 55 | } 56 | } 57 | } 58 | } 59 | root 60 | } 61 | 62 | fn init( 63 | params: Self::InitParams, 64 | root: &Self::Root, 65 | sender: &relm4::ComponentSender, 66 | ) -> relm4::ComponentParts { 67 | let scanner_link = LinkCopierModel::builder() 68 | .launch( 69 | link_copier::Payload::builder() 70 | .url("https://github.com/mzdluo123/TxCaptchaHelper".to_string()) 71 | .build(), 72 | ) 73 | .forward(&sender.output, |msg| match msg { 74 | link_copier::Output::LinkCopied => LoginPageMsg::LinkCopied, 75 | }); 76 | 77 | let verify_link = LinkCopierModel::builder() 78 | .launch( 79 | link_copier::Payload::builder() 80 | .url(params.verify_url.clone()) 81 | .label("Verification Link".into()) 82 | .build(), 83 | ) 84 | .forward(&sender.output, |msg| match msg { 85 | link_copier::Output::LinkCopied => LoginPageMsg::LinkCopied, 86 | }); 87 | 88 | relm4::view! { 89 | #[name = "body"] 90 | Box { 91 | set_valign: Align::Center, 92 | set_halign: Align::Center, 93 | set_vexpand: true, 94 | set_spacing: 24, 95 | Box { 96 | set_margin_all: 16, 97 | set_orientation: Orientation::Vertical, 98 | set_halign: Align::Start, 99 | set_spacing: 8, 100 | Label { 101 | set_xalign: 0.0, 102 | set_label: "1. Install the tool on your Android phone: " 103 | }, 104 | append: scanner_link.widget(), 105 | Label { 106 | set_xalign: 0.0, 107 | set_label: "2. Scan the qrcode and get the ticket." 108 | }, 109 | Box { 110 | set_orientation: Orientation::Horizontal, 111 | Label { 112 | set_label: "3. " 113 | }, 114 | #[name = "ticket_input"] 115 | Entry { 116 | set_placeholder_text: Some("Paste the ticket here..."), 117 | set_margin_end: 8, 118 | set_activates_default: true, 119 | connect_activate[sender] => move |_|{ 120 | sender.input(Input::Submit); 121 | }, 122 | connect_changed[sender] => move |entry|{ 123 | let ticket = entry.buffer().text(); 124 | sender.input(Input::UpdateTicket(ticket)); 125 | }, 126 | }, 127 | #[name = "ticket_submit_button"] 128 | Button { 129 | set_label: "Submit Ticket" 130 | } 131 | }, 132 | Box { 133 | set_orientation: Orientation::Vertical, 134 | Label { 135 | set_xalign: 0.0, 136 | set_label: "Help: If you do not have an Android phone to install the tool, open the" 137 | }, 138 | append: verify_link.widget(), 139 | Label { 140 | set_xalign: 0.0, 141 | set_label: "in the browser manually, open the devtools and switch to the network panel. After you passed the" 142 | }, 143 | Label { 144 | set_xalign: 0.0, 145 | set_label: "verification, you will find a request whose response contains the `ticket`. Then just paste it" 146 | }, 147 | Label { 148 | set_xalign: 0.0, 149 | set_label: "above. The result would be same. It just maybe more complex if you don't know devtools well." 150 | }, 151 | } 152 | }, 153 | Picture { 154 | set_width_request: 240, 155 | set_can_shrink: true, 156 | set_filename: Some(&CaptchaQrCode::get_path()) 157 | } 158 | } 159 | } 160 | 161 | ticket_submit_button.connect_clicked(clone!(@strong sender => move |_| { 162 | sender.input(Input::Submit); 163 | })); 164 | 165 | root.append(&body); 166 | 167 | relm4::ComponentParts { 168 | model: CaptchaModel { 169 | client: params.client, 170 | ticket: String::new(), 171 | }, 172 | widgets: CaptchaWidgets { 173 | window: params.window, 174 | }, 175 | } 176 | } 177 | 178 | fn update_with_view( 179 | &mut self, 180 | widgets: &mut Self::Widgets, 181 | message: Self::Input, 182 | sender: &ComponentSender, 183 | ) { 184 | match message { 185 | Input::Submit => { 186 | task::spawn(self.clone().submit_ticket(sender.clone())); 187 | } 188 | Input::UpdateTicket(new_ticket) => { 189 | self.ticket = new_ticket; 190 | } 191 | Input::CloseWindow => { 192 | widgets.window.close(); 193 | } 194 | } 195 | } 196 | } 197 | 198 | impl CaptchaModel { 199 | async fn submit_ticket(self, sender: ComponentSender) { 200 | match self.client.submit_ticket(&self.ticket).await { 201 | Ok(res) => sender.output(LoginPageMsg::LoginRespond(res.into())), 202 | Err(err) => { 203 | sender.output(LoginPageMsg::LoginFailed(err.to_string())); 204 | } 205 | } 206 | sender.input(Input::CloseWindow); 207 | } 208 | } 209 | -------------------------------------------------------------------------------- /src/app/login/device_lock/mod.rs: -------------------------------------------------------------------------------- 1 | use relm4::{adw, gtk, Component, ComponentController, SimpleComponent, WidgetPlus}; 2 | 3 | use adw::{HeaderBar, Window}; 4 | use gtk::prelude::*; 5 | use gtk::{Align, Box, Button, Label, Orientation}; 6 | 7 | use widgets::link_copier::{self, LinkCopierModel}; 8 | 9 | use super::LoginPageMsg; 10 | 11 | pub struct DeviceLock; 12 | 13 | #[derive(Debug)] 14 | pub struct Payload { 15 | pub(crate) window: Window, 16 | pub(crate) unlock_url: String, 17 | pub(crate) sms_phone: Option, 18 | } 19 | 20 | impl SimpleComponent for DeviceLock { 21 | type Input = (); 22 | type Output = LoginPageMsg; 23 | type InitParams = Payload; 24 | type Root = Box; 25 | type Widgets = (); 26 | 27 | fn init_root() -> Self::Root { 28 | relm4::view! { 29 | #[root] 30 | #[name = "root"] 31 | Box { 32 | set_orientation: Orientation::Vertical, 33 | HeaderBar { 34 | set_title_widget = Some(&Label) { 35 | set_label: "Device Lock Verify Introduction" 36 | } 37 | } 38 | } 39 | } 40 | root 41 | } 42 | 43 | fn init( 44 | params: Self::InitParams, 45 | root: &Self::Root, 46 | sender: &relm4::ComponentSender, 47 | ) -> relm4::ComponentParts { 48 | relm4::view! { 49 | body = Box { 50 | set_orientation: Orientation::Vertical, 51 | set_valign: Align::Center, 52 | set_halign: Align::Center, 53 | set_vexpand: true, 54 | set_spacing: 24, 55 | set_margin_all: 16, 56 | Label { 57 | set_label: &format!( 58 | "Please open the link below and use your logged in device[sms:{}] to verify", 59 | params.sms_phone.unwrap_or_else(|| "".into()) 60 | ) 61 | }, 62 | append: LinkCopierModel::builder() 63 | .launch( 64 | link_copier::Payload::builder() 65 | .url(params.unlock_url) 66 | .label("Device Lock Verification".into()) 67 | .build(), 68 | ) 69 | .forward(sender.output_sender(), |msg| match msg { 70 | link_copier::Output::LinkCopied => LoginPageMsg::LinkCopied, 71 | }) 72 | .widget(), 73 | Label { 74 | set_label: "Once verified, click the button below" 75 | }, 76 | Button { 77 | set_label: "Confirm Verification", 78 | connect_clicked[sender] => move |_| { 79 | sender.output(LoginPageMsg::ConfirmVerification); 80 | params.window.close(); 81 | }, 82 | } 83 | } 84 | } 85 | 86 | root.append(&body); 87 | 88 | relm4::ComponentParts { 89 | model: Self, 90 | widgets: (), 91 | } 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/app/login/mod.rs: -------------------------------------------------------------------------------- 1 | mod captcha; 2 | mod device_lock; 3 | mod service; 4 | 5 | use crate::{ 6 | actions::{AboutAction, ShortcutsAction}, 7 | app::{ 8 | login::service::login_server::{Login, Switch}, 9 | AppMessage, 10 | }, 11 | db::{ 12 | fs::{download_user_avatar_file, get_user_avatar_path}, 13 | sql::{load_sql_config, save_sql_config}, 14 | }, 15 | global::WINDOW, 16 | gtk::Button, 17 | }; 18 | 19 | use std::{ 20 | boxed, 21 | cell::RefCell, 22 | sync::{ 23 | atomic::{AtomicBool, Ordering}, 24 | Arc, 25 | }, 26 | }; 27 | 28 | use relm4::{ 29 | adw, 30 | gtk::{self, gdk::Paintable, Align, Stack}, 31 | Component, ComponentController, ComponentParts, ComponentSender, SimpleComponent, 32 | }; 33 | 34 | use adw::{prelude::*, HeaderBar, Toast, ToastOverlay, Window}; 35 | 36 | use gtk::{gdk_pixbuf::Pixbuf, Box, Label, MenuButton, Orientation, Picture}; 37 | 38 | use resource_loader::GetPath; 39 | use ricq::{client::Token, Client, LoginResponse}; 40 | use tokio::task; 41 | use widgets::{ 42 | pwd_login::{self, Input, PasswordLogin, PasswordLoginModel, Payload}, 43 | qrcode_login::{self, QrCodeLogin, QrCodeLoginModel}, 44 | }; 45 | 46 | use self::service::{ 47 | login_server::{self, LoginHandle, Sender}, 48 | token::LocalAccount, 49 | }; 50 | 51 | type SmsPhone = Option; 52 | type VerifyUrl = String; 53 | 54 | pub(in crate::app::login) static REMEMBER_PWD: AtomicBool = AtomicBool::new(false); 55 | pub(in crate::app::login) static AUTO_LOGIN: AtomicBool = AtomicBool::new(false); 56 | 57 | #[derive(Debug, Default)] 58 | pub enum LoginState { 59 | #[default] 60 | Password, 61 | QrCode, 62 | } 63 | 64 | #[derive(Debug)] 65 | pub struct LoginPageModel { 66 | login_btn_enabled: bool, 67 | is_logging: bool, 68 | pwd_login: PasswordLogin, 69 | qr_code_login: QrCodeLogin, 70 | toast: RefCell>, 71 | sender: Option, 72 | login_state: LoginState, 73 | } 74 | 75 | pub enum LoginPageMsg { 76 | ClientInit(LoginHandle), 77 | 78 | LoginSwitch(LoginState), 79 | 80 | StartLogin, 81 | PwdLogin(i64, String), 82 | TokenLogin(Token), 83 | LoginRespond(boxed::Box), 84 | LoginSuccessful(Arc), 85 | 86 | LoginFailed(String), 87 | NeedCaptcha(String, Arc), 88 | DeviceLock(VerifyUrl, SmsPhone), 89 | ConfirmVerification, 90 | 91 | EnableLogin(bool), 92 | RememberPwd(bool), 93 | AutoLogin(bool), 94 | UpdateQrCode, 95 | 96 | LinkCopied, 97 | } 98 | 99 | #[relm4::component(pub)] 100 | impl SimpleComponent for LoginPageModel { 101 | type Input = LoginPageMsg; 102 | type Output = AppMessage; 103 | type InitParams = (); 104 | type Widgets = LoginPageWidgets; 105 | 106 | fn init( 107 | _init_params: Self::InitParams, 108 | root: &Self::Root, 109 | sender: &ComponentSender, 110 | ) -> ComponentParts { 111 | // start client 112 | let t_sender = sender.input_sender().clone(); 113 | tokio::spawn(async move { 114 | t_sender.send(LoginPageMsg::ClientInit( 115 | LoginHandle::new(t_sender.clone()).await, 116 | )) 117 | }); 118 | 119 | // load config 120 | REMEMBER_PWD.store( 121 | load_sql_config("remember_pwd") 122 | .ok() 123 | .flatten() 124 | .and_then(|v| v.parse().ok()) 125 | .unwrap_or(false), 126 | Ordering::Relaxed, 127 | ); 128 | 129 | AUTO_LOGIN.store( 130 | load_sql_config("auto_login") 131 | .ok() 132 | .flatten() 133 | .and_then(|v| v.parse().ok()) 134 | .unwrap_or(false), 135 | Ordering::Relaxed, 136 | ); 137 | 138 | // load saved account 139 | let account = if !REMEMBER_PWD.load(Ordering::Relaxed) { 140 | None 141 | } else { 142 | LocalAccount::get_account() 143 | }; 144 | let account_ref = Into::>::into(&account); 145 | let avatar = load_avatar(account_ref.map(|a| a.account), true); 146 | 147 | // init pwd login 148 | let pwd_login = PasswordLoginModel::builder() 149 | .launch(Payload { 150 | account: account_ref.map(|a| a.account), 151 | avatar, 152 | token: account.map(|a| a.token), 153 | remember_pwd: REMEMBER_PWD.load(Ordering::Relaxed), 154 | auto_login: AUTO_LOGIN.load(Ordering::Relaxed), 155 | }) 156 | .forward(sender.input_sender(), |out| match out { 157 | pwd_login::Output::Login { account, pwd } => LoginPageMsg::PwdLogin(account, pwd), 158 | pwd_login::Output::EnableLogin(enable) => LoginPageMsg::EnableLogin(enable), 159 | pwd_login::Output::TokenLogin(token) => LoginPageMsg::TokenLogin(token), 160 | pwd_login::Output::RememberPwd(b) => LoginPageMsg::RememberPwd(b), 161 | pwd_login::Output::AutoLogin(b) => LoginPageMsg::AutoLogin(b), 162 | }); 163 | 164 | // init qr code login 165 | let qr_code_login = QrCodeLoginModel::builder() 166 | .launch(widgets::qrcode_login::PayLoad { 167 | temp_img_path: resource_loader::QrCodeLoginCode::get_path(), 168 | }) 169 | .forward(sender.input_sender(), |out| match out {}); 170 | 171 | let widgets = view_output!(); 172 | 173 | let model = LoginPageModel { 174 | login_btn_enabled: false, 175 | is_logging: false, 176 | pwd_login, 177 | qr_code_login, 178 | login_state: Default::default(), 179 | toast: RefCell::new(None), 180 | sender: None, 181 | }; 182 | 183 | ComponentParts { model, widgets } 184 | } 185 | 186 | fn update(&mut self, msg: LoginPageMsg, sender: &ComponentSender) { 187 | use LoginPageMsg::*; 188 | match msg { 189 | UpdateQrCode => { 190 | self.qr_code_login.emit(qrcode_login::Input::UpdateQrCode); 191 | } 192 | ClientInit(client) => { 193 | self.sender.replace(client.get_sender()); 194 | client.start_handle(); 195 | } 196 | LoginSwitch(target) => { 197 | match (&target, &mut self.sender) { 198 | (LoginState::Password, Some(sender)) => { 199 | sender.send(login_server::Input::Switch(Switch::Password)) 200 | } 201 | (LoginState::QrCode, Some(sender)) => { 202 | sender.send(login_server::Input::Switch(Switch::QrCode)) 203 | } 204 | (_, _) => sender.input(LoginFailed("Client Not Init. Please Wait".into())), 205 | }; 206 | self.login_state = target; 207 | } 208 | LoginRespond(boxed_login_resp) => { 209 | if let Some(sender) = &mut self.sender { 210 | sender.send(login_server::Input::LoginRespond(boxed_login_resp)) 211 | } else { 212 | sender.input(LoginFailed("Client Not Init. Please Wait".into())); 213 | } 214 | } 215 | RememberPwd(b) => { 216 | REMEMBER_PWD.store(b, Ordering::Relaxed); 217 | } 218 | AutoLogin(b) => { 219 | AUTO_LOGIN.store(b, Ordering::Relaxed); 220 | } 221 | TokenLogin(token) => { 222 | if let Some(sender) = &mut self.sender { 223 | sender.send(login_server::Input::Login(Login::Token(token.into()))) 224 | } else { 225 | sender.input(LoginFailed("Client Not Init. Please Wait".into())); 226 | } 227 | } 228 | EnableLogin(enabled) => { 229 | self.login_btn_enabled = enabled && self.sender.is_some() && !self.is_logging; 230 | } 231 | StartLogin => { 232 | self.login_btn_enabled = false; 233 | self.is_logging = true; 234 | self.pwd_login.emit(Input::Login); 235 | } 236 | PwdLogin(uin, pwd) => { 237 | if let Some(sender) = &mut self.sender { 238 | sender.send(login_server::Input::Login(Login::Pwd(uin, pwd))) 239 | } else { 240 | sender.input(LoginFailed("Client Not Init. Please Wait".into())); 241 | } 242 | } 243 | LoginSuccessful(_) => { 244 | self.save_login_setting(); 245 | sender.output(AppMessage::LoginSuccessful); 246 | } 247 | LoginFailed(msg) => { 248 | self.login_btn_enabled = true; 249 | self.is_logging = false; 250 | *(self.toast.borrow_mut()) = Some(msg); 251 | } 252 | NeedCaptcha(verify_url, client) => { 253 | sender.input(LoginPageMsg::LoginFailed( 254 | "Need Captcha. See more in the pop-up window.".to_string(), 255 | )); 256 | let window = Window::builder() 257 | .transient_for(&WINDOW.get().unwrap().window) 258 | .default_width(640) 259 | .build(); 260 | 261 | window.connect_destroy(|_| println!("closed window")); 262 | 263 | let verify_url = verify_url.replace('&', "&"); 264 | 265 | let captcha = captcha::CaptchaModel::builder() 266 | .launch(captcha::PayLoad { 267 | client: Arc::clone(&client), 268 | verify_url, 269 | window: window.clone(), 270 | }) 271 | .forward(sender.input_sender(), |output| output); 272 | 273 | window.set_content(Some(captcha.widget())); 274 | window.present(); 275 | } 276 | 277 | DeviceLock(verify_url, sms) => { 278 | let window = Window::builder() 279 | .transient_for(&WINDOW.get().unwrap().window) 280 | .default_width(640) 281 | .build(); 282 | 283 | let device_lock = device_lock::DeviceLock::builder() 284 | .launch(device_lock::Payload { 285 | window: window.clone(), 286 | unlock_url: verify_url, 287 | sms_phone: sms, 288 | }) 289 | .forward(sender.input_sender(), |output| output); 290 | 291 | window.set_content(Some(device_lock.widget())); 292 | window.present() 293 | } 294 | // TODO: proc follow operate 295 | ConfirmVerification => sender.input(LoginPageMsg::StartLogin), 296 | LinkCopied => { 297 | self.toast.borrow_mut().replace("Link Copied".into()); 298 | } 299 | } 300 | } 301 | 302 | fn shutdown(&mut self, _: &mut Self::Widgets, _: relm4::Sender) { 303 | self.save_login_setting() 304 | } 305 | 306 | menu! { 307 | main_menu: { 308 | "Keyboard Shortcuts" => ShortcutsAction, 309 | "About Gtk QQ" => AboutAction 310 | } 311 | } 312 | 313 | view! { 314 | login_page = Box { 315 | set_hexpand: true, 316 | set_vexpand: true, 317 | set_orientation: Orientation::Vertical, 318 | #[name = "headerbar"] 319 | HeaderBar { 320 | set_title_widget = Some(&Label) { 321 | set_label: "Login" 322 | }, 323 | pack_end: login_btn = &Button{ 324 | set_icon_name : "go-next", 325 | set_sensitive : false, 326 | connect_clicked[sender] => move |_|{ 327 | sender.input(LoginPageMsg::StartLogin) 328 | } 329 | }, 330 | pack_end = &MenuButton { 331 | set_icon_name: "menu-symbolic", 332 | set_menu_model: Some(&main_menu), 333 | }, 334 | pack_start: qrcode_switcher = &Button{ 335 | set_icon_name : "qr-code-symbolic", 336 | connect_clicked[sender] => move |this|{ 337 | if this.icon_name().unwrap() == "qr-code-symbolic"{ 338 | this.set_icon_name("keyboard-symbolic"); 339 | sender.input(LoginPageMsg::EnableLogin(false)); 340 | sender.input(LoginPageMsg::LoginSwitch(LoginState::QrCode)); 341 | } else { 342 | this.set_icon_name("qr-code-symbolic"); 343 | sender.input(LoginPageMsg::EnableLogin(true)); 344 | sender.input(LoginPageMsg::LoginSwitch(LoginState::Password)); 345 | } 346 | } 347 | } 348 | }, 349 | #[name = "toast_overlay"] 350 | ToastOverlay { 351 | set_child = Some(>k::Box){ 352 | set_orientation:gtk::Orientation::Vertical, 353 | set_halign:Align::Center, 354 | set_valign:Align::Center, 355 | append : stack= &Stack{ 356 | set_halign:Align::Center, 357 | set_valign:Align::Center, 358 | add_child : pwd_login_box = >k::Box { 359 | append : pwd_login.widget(), 360 | }, 361 | add_child: qr_code_login_box = >k::Box{ 362 | append: qr_code_login.widget(), 363 | } 364 | } 365 | } 366 | } 367 | } 368 | } 369 | 370 | fn pre_view(&self, widgets: &mut Self::Widgets, sender: &ComponentSender) { 371 | if let Some(ref content) = self.toast.borrow_mut().take() { 372 | widgets.toast_overlay.add_toast(&Toast::new(content)); 373 | } 374 | widgets.login_btn.set_sensitive(self.login_btn_enabled); 375 | 376 | match self.login_state { 377 | LoginState::Password => stack.set_visible_child(pwd_login_box), 378 | LoginState::QrCode => stack.set_visible_child(qr_code_login_box), 379 | } 380 | } 381 | } 382 | 383 | impl LoginPageModel { 384 | fn save_login_setting(&self) { 385 | save_sql_config( 386 | "remember_pwd", 387 | REMEMBER_PWD.load(Ordering::Relaxed).to_string(), 388 | ) 389 | .expect("Save cfg Error"); 390 | save_sql_config("auto_login", AUTO_LOGIN.load(Ordering::Relaxed).to_string()) 391 | .expect("Save cfg Error"); 392 | } 393 | } 394 | 395 | fn load_avatar(account: Option, auto_download: bool) -> Option { 396 | account 397 | .map(|uin| (uin, get_user_avatar_path(uin))) 398 | .and_then(|(uin, path)| { 399 | if path.exists() { 400 | Some(path) 401 | } else { 402 | if auto_download { 403 | task::spawn(download_user_avatar_file(uin)); 404 | } 405 | None 406 | } 407 | }) 408 | .and_then(|path| Pixbuf::from_file_at_size(path, 96, 96).ok()) 409 | .map(|pix| Picture::for_pixbuf(&pix)) 410 | .and_then(|pic| pic.paintable()) 411 | } 412 | -------------------------------------------------------------------------------- /src/app/login/service.rs: -------------------------------------------------------------------------------- 1 | use relm4::Sender; 2 | use resource_loader::SyncLoadResource; 3 | use std::{ 4 | io, 5 | sync::{atomic::Ordering, Arc}, 6 | }; 7 | 8 | use qrcode_png::{Color, QrCode}; 9 | 10 | use ricq::{ 11 | client::{Connector, DefaultConnector}, 12 | ext::common::after_login, 13 | Client, LoginUnknownStatus, 14 | }; 15 | use tokio::task; 16 | 17 | use crate::app::login::{service::token::LocalAccount, LoginPageMsg, REMEMBER_PWD}; 18 | 19 | use crate::handler::{AppHandler, ACCOUNT, CLIENT}; 20 | 21 | pub(super) mod handle_respond; 22 | pub mod login_server; 23 | pub(super) mod pwd_login; 24 | pub mod token; 25 | 26 | pub(crate) async fn init_client() -> io::Result> { 27 | let client = Arc::new(Client::new( 28 | resource_loader::Device::load_resource(()).unwrap(), 29 | resource_loader::Protocol::load_resource(()).unwrap(), 30 | AppHandler, 31 | )); 32 | 33 | // Connect to server 34 | tokio::spawn({ 35 | let client = client.clone(); 36 | // 连接所有服务器,哪个最快用哪个,可以使用 TcpStream::connect 代替 37 | let stream = DefaultConnector.connect(&client).await.unwrap(); 38 | async move { client.start(stream).await } 39 | }); 40 | 41 | task::yield_now().await; 42 | 43 | Ok(client) 44 | } 45 | 46 | pub(crate) async fn finish_login(client: Arc, sender: &Sender) { 47 | let local = LocalAccount::new(&client).await; 48 | 49 | use LoginPageMsg::LoginSuccessful; 50 | if CLIENT.set(client.clone()).is_err() { 51 | panic!("falied to store client"); 52 | }; 53 | if ACCOUNT.set(local.account).is_err() { 54 | panic!("falied to store account"); 55 | }; 56 | if REMEMBER_PWD.load(Ordering::Relaxed) { 57 | local.save_account(sender); 58 | } 59 | 60 | after_login(&client).await; 61 | sender.send(LoginSuccessful(client)); 62 | } 63 | -------------------------------------------------------------------------------- /src/app/login/service/handle_respond.rs: -------------------------------------------------------------------------------- 1 | use crate::app::login::{ 2 | service::{finish_login, Color, LoginUnknownStatus, QrCode}, 3 | Arc, LoginPageMsg, 4 | }; 5 | use qrcode_png::QrCodeEcc; 6 | use relm4::Sender; 7 | use resource_loader::{AsyncCreatePath, CaptchaQrCode}; 8 | use ricq::{Client, LoginDeviceLocked, LoginNeedCaptcha, LoginResponse}; 9 | use tokio::fs; 10 | 11 | pub(in crate::app) async fn handle_login_response( 12 | res: &LoginResponse, 13 | client: &Arc, 14 | sender: &Sender, 15 | ) { 16 | use LoginPageMsg::LoginFailed; 17 | match res { 18 | LoginResponse::Success(_) => { 19 | finish_login(Arc::clone(client), sender).await; 20 | } 21 | LoginResponse::NeedCaptcha(LoginNeedCaptcha { verify_url, .. }) => { 22 | let verify_url = Into::>::into(verify_url); 23 | let operate = || async { 24 | // Get the captcha url qrcode image path 25 | let path = CaptchaQrCode::create_and_get_path_async() 26 | .await 27 | .map_err(|err| err.to_string())?; 28 | 29 | // Generate qrcode image 30 | let verify_url = verify_url.unwrap(); 31 | let qrcode = QrCode::new(&verify_url, QrCodeEcc::Low) 32 | .map_err(|err| err.to_string())? 33 | .margin(10) 34 | .zoom(5) 35 | .generate(Color::Grayscale(0, 255)) 36 | .map_err(|err| err.to_string())?; 37 | 38 | // Write the image 39 | fs::write(path, qrcode) 40 | .await 41 | .map_err(|err| err.to_string())?; 42 | 43 | sender.send(LoginPageMsg::NeedCaptcha( 44 | verify_url.clone(), 45 | Arc::clone(client), 46 | )); 47 | Result::<_, String>::Ok(()) 48 | }; 49 | 50 | if let Err(err) = operate().await { 51 | sender.send(LoginFailed(err)); 52 | } 53 | } 54 | LoginResponse::AccountFrozen => { 55 | sender.send(LoginFailed("Account Frozen".to_string())); 56 | } 57 | LoginResponse::DeviceLocked(LoginDeviceLocked { 58 | sms_phone, 59 | verify_url, 60 | .. 61 | }) => { 62 | sender.send(LoginFailed( 63 | "Device Locked. See more in the pop-up window.".to_string(), 64 | )); 65 | let verify_url = if let Some(url) = verify_url { 66 | url.to_owned() 67 | } else { 68 | sender.send(LoginFailed("Cannot Fetch Device Lock".to_string())); 69 | return; 70 | }; 71 | sender.send(LoginPageMsg::DeviceLock(verify_url, sms_phone.clone())); 72 | } 73 | LoginResponse::TooManySMSRequest => { 74 | sender.send(LoginFailed("Too Many SMS Request".to_string())); 75 | } 76 | LoginResponse::DeviceLockLogin(_) => match client.device_lock_login().await { 77 | Err(err) => { 78 | sender.send(LoginFailed(err.to_string())); 79 | } 80 | Ok(_) => { 81 | finish_login(Arc::clone(client), sender).await; 82 | } 83 | }, 84 | LoginResponse::UnknownStatus(LoginUnknownStatus { message, .. }) => { 85 | sender.send(LoginFailed(message.clone())); 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/app/login/service/login_server.rs: -------------------------------------------------------------------------------- 1 | use std::{sync::Arc, time::Duration}; 2 | 3 | use resource_loader::{GetPath, QrCodeLoginCode}; 4 | use ricq::{client::Token, Client, LoginResponse}; 5 | use tokio::{ 6 | sync::{ 7 | mpsc::{self, error::TrySendError}, 8 | oneshot::{self, error::TryRecvError}, 9 | }, 10 | task::JoinHandle, 11 | time::interval, 12 | }; 13 | 14 | use crate::app::login::LoginPageMsg; 15 | 16 | use super::{handle_respond::handle_login_response, init_client}; 17 | 18 | pub enum Login { 19 | Pwd(i64, String), 20 | Token(Box), 21 | } 22 | 23 | pub enum Switch { 24 | Password, 25 | QrCode, 26 | } 27 | 28 | enum LocalState { 29 | Pwd, 30 | QrCode(oneshot::Sender<()>, JoinHandle<()>), 31 | } 32 | 33 | pub enum Input { 34 | // switch how to login 35 | Switch(Switch), 36 | // login 37 | Login(Login), 38 | // login proc 39 | LoginRespond(Box), 40 | #[allow(dead_code)] 41 | Stop, 42 | } 43 | 44 | #[derive(Debug)] 45 | pub struct Sender { 46 | feedback: Option>, 47 | tx: mpsc::Sender<(Input, oneshot::Sender<()>)>, 48 | sender: relm4::Sender, 49 | } 50 | 51 | impl Clone for Sender { 52 | fn clone(&self) -> Self { 53 | Self { 54 | feedback: None, 55 | tx: self.tx.clone(), 56 | sender: self.sender.clone(), 57 | } 58 | } 59 | } 60 | 61 | impl Sender { 62 | pub fn send(&mut self, input: Input) { 63 | // check is ready to handle next operate 64 | if let Some(fb) = &mut self.feedback { 65 | match fb.try_recv() { 66 | Ok(_) => { 67 | self.feedback.take(); 68 | } 69 | Err(err) => match err { 70 | TryRecvError::Empty => self.sender.send(LoginPageMsg::LoginFailed( 71 | "Previous login task not finish yet,please wait".into(), 72 | )), 73 | TryRecvError::Closed => self 74 | .sender 75 | .send(LoginPageMsg::LoginFailed("Login Server Closed".into())), 76 | }, 77 | } 78 | } 79 | // ready to handle next operate 80 | if self.feedback.is_none() { 81 | let (tx, rx) = oneshot::channel(); 82 | match self.tx.try_send((input, tx)) { 83 | Ok(_r) => { 84 | self.feedback.replace(rx); 85 | } 86 | Err(err) => match err { 87 | TrySendError::Full(_) => self.sender.send(LoginPageMsg::LoginFailed( 88 | "Channel Buff Full,Please wait".into(), 89 | )), 90 | TrySendError::Closed(_) => self 91 | .sender 92 | .send(LoginPageMsg::LoginFailed("Login Server Closed".into())), 93 | }, 94 | } 95 | } 96 | } 97 | } 98 | 99 | pub struct LoginHandle { 100 | client: Arc, 101 | rx: mpsc::Receiver<(Input, oneshot::Sender<()>)>, 102 | sender: relm4::Sender, 103 | inner_send: Sender, 104 | } 105 | 106 | impl LoginHandle { 107 | pub async fn new(sender: relm4::Sender) -> LoginHandle { 108 | let client = init_client().await.expect("Init Client Error"); 109 | let (tx, rx) = mpsc::channel(8); 110 | 111 | Self { 112 | client, 113 | rx, 114 | sender: sender.clone(), 115 | inner_send: Sender { 116 | tx, 117 | sender, 118 | feedback: None, 119 | }, 120 | } 121 | } 122 | 123 | pub fn get_sender(&self) -> Sender { 124 | self.inner_send.clone() 125 | } 126 | } 127 | 128 | impl LoginHandle { 129 | pub fn start_handle(mut self) -> JoinHandle<()> { 130 | let task = async move { 131 | let mut state = LocalState::Pwd; 132 | let mut timer = interval(Duration::from_millis(2000)); 133 | 134 | while let Some((input, sender)) = self.rx.recv().await { 135 | match (input, &state) { 136 | (Input::Login(_), LocalState::QrCode(..)) => { 137 | self.sender.send(LoginPageMsg::LoginFailed( 138 | "Under `QrCodeLogin` state can not using password login".to_string(), 139 | )); 140 | } 141 | // only work when using password login 142 | (Input::Login(login), LocalState::Pwd) => { 143 | timer.tick().await; 144 | match login { 145 | Login::Pwd(account, pwd) => { 146 | super::pwd_login::login(account, pwd, &self.sender, &self.client) 147 | .await; 148 | } 149 | Login::Token(token) => { 150 | super::token::token_login(*token, &self.sender, &self.client).await; 151 | } 152 | } 153 | } 154 | (Input::LoginRespond(resp), _) => { 155 | handle_login_response(&resp, &self.client, &self.sender).await; 156 | } 157 | (Input::Switch(s), _) => { 158 | timer.tick().await; 159 | let new_state = match (state, s) { 160 | (LocalState::Pwd, Switch::QrCode) => { 161 | let (tx, rx) = oneshot::channel(); 162 | let jh = tokio::spawn(qr_login( 163 | self.client.clone(), 164 | self.sender.clone(), 165 | rx, 166 | )); 167 | 168 | println!("switch to QR"); 169 | LocalState::QrCode(tx, jh) 170 | } 171 | (LocalState::QrCode(tx, jh), Switch::Password) => { 172 | // send stop signal 173 | tx.send(()).ok(); 174 | // waiting for stop 175 | jh.await.ok(); 176 | println!("switch to PWD"); 177 | LocalState::Pwd 178 | } 179 | (state, _) => { 180 | // switch to same mod, nothing happen 181 | state 182 | } 183 | }; 184 | state = new_state; 185 | } 186 | (Input::Stop, _) => break, 187 | } 188 | sender.send(()).ok(); 189 | } 190 | }; 191 | 192 | tokio::spawn(task) 193 | } 194 | } 195 | 196 | async fn qr_login( 197 | client: Arc, 198 | sender: relm4::Sender, 199 | mut stopper: oneshot::Receiver<()>, 200 | ) { 201 | use LoginPageMsg::*; 202 | let temp_path = QrCodeLoginCode::get_path(); 203 | let mut timer = interval(Duration::from_millis(400)); 204 | let mut qrcode_state = match client.fetch_qrcode().await { 205 | Ok(qrcode) => qrcode, 206 | Err(err) => { 207 | sender.send(LoginFailed(err.to_string())); 208 | return; 209 | } 210 | }; 211 | 212 | let mut qrcode_sign = Option::None; 213 | while let Err(TryRecvError::Empty) = stopper.try_recv() { 214 | match qrcode_state { 215 | ricq::QRCodeState::ImageFetch(ref qrcode) => { 216 | let img = &qrcode.image_data; 217 | tokio::fs::write(temp_path, &img) 218 | .await 219 | .expect("failure to write qrcode file"); 220 | qrcode_sign.replace(qrcode.sig.clone()); 221 | sender.send(UpdateQrCode); 222 | println!("QrCode fetch ,save in {:?}", temp_path); 223 | } 224 | ricq::QRCodeState::WaitingForScan => { 225 | println!("waiting for scan qrcode"); 226 | } 227 | ricq::QRCodeState::WaitingForConfirm => { 228 | println!("waiting for qrcode confirm"); 229 | } 230 | ricq::QRCodeState::Timeout => { 231 | println!("QrCode Timeout fetch again"); 232 | match client.fetch_qrcode().await { 233 | Ok(qr_state) => { 234 | qrcode_state = qr_state; 235 | continue; 236 | } 237 | Err(err) => { 238 | sender.send(LoginFailed(err.to_string())); 239 | return; 240 | } 241 | } 242 | } 243 | ricq::QRCodeState::Confirmed(ref qrcode_confirm) => { 244 | println!("QrCode confirmed, ready for login"); 245 | let login_respond = client 246 | .qrcode_login( 247 | &qrcode_confirm.tmp_pwd, 248 | &qrcode_confirm.tmp_no_pic_sig, 249 | &qrcode_confirm.tgt_qr, 250 | ) 251 | .await; 252 | match login_respond { 253 | Ok(ok_respond) => sender.send(LoginRespond(ok_respond.into())), 254 | Err(err) => sender.send(LoginFailed(err.to_string())), 255 | } 256 | return; 257 | } 258 | ricq::QRCodeState::Canceled => { 259 | println!("Canceled QrCode"); 260 | sender.send(LoginFailed("QrCode login has been canceled".into())); 261 | return; 262 | } 263 | } 264 | 265 | timer.tick().await; 266 | let qrcode_sig = qrcode_sign 267 | .as_ref() 268 | .map(|byte| -> &[u8] { byte }) 269 | .unwrap_or(&[]); 270 | qrcode_state = match client.query_qrcode_result(qrcode_sig).await { 271 | Ok(state) => state, 272 | Err(err) => { 273 | sender.send(LoginFailed(err.to_string())); 274 | return; 275 | } 276 | } 277 | } 278 | } 279 | -------------------------------------------------------------------------------- /src/app/login/service/pwd_login.rs: -------------------------------------------------------------------------------- 1 | use ricq::Client; 2 | 3 | use crate::app::login::LoginPageMsg; 4 | 5 | pub(crate) async fn login( 6 | account: i64, 7 | password: String, 8 | sender: &relm4::Sender, 9 | client: &Client, 10 | ) { 11 | use crate::app::login::LoginPageMsg::{LoginFailed, LoginRespond}; 12 | 13 | let operate = || async { 14 | sender.send(LoginRespond( 15 | client 16 | .password_login(account, &password) 17 | .await 18 | .map_err(|err| err.to_string())? 19 | .into(), 20 | )); 21 | 22 | Result::<_, String>::Ok(()) 23 | }; 24 | 25 | if let Err(err) = operate().await { 26 | sender.send(LoginFailed(err)) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/app/login/service/token.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | app::login::LoginPageMsg::{LoginFailed, LoginRespond}, 3 | db::sql::{load_sql_config, save_sql_config}, 4 | }; 5 | use relm4::Sender; 6 | use ricq::{client::Token, Client}; 7 | 8 | use crate::app::login::LoginPageMsg; 9 | 10 | pub struct LocalAccount { 11 | pub account: i64, 12 | pub token: Token, 13 | } 14 | 15 | impl LocalAccount { 16 | fn token_to_base64(token: &Token) -> String { 17 | let vec = bincode::serialize(&token).expect("serde token error"); 18 | base64::encode(vec) 19 | } 20 | 21 | fn base64_to_token(base64: &str) -> Token { 22 | let vec = base64::decode(base64).expect("Bad Base64 Encode"); 23 | bincode::deserialize(&vec).expect("Bad Bincode format") 24 | } 25 | 26 | pub async fn new(client: &Client) -> Self { 27 | let uin = client.uin().await; 28 | let token = client.gen_token().await; 29 | 30 | Self { 31 | account: uin, 32 | token, 33 | } 34 | } 35 | 36 | pub fn save_account(&self, sender: &Sender) { 37 | let account = self.account.to_string(); 38 | let token = Self::token_to_base64(&self.token); 39 | 40 | let saving = || { 41 | save_sql_config(&"account", &account)?; 42 | save_sql_config(&"token", &token) 43 | }; 44 | if let Err(err) = saving() { 45 | sender.send(LoginFailed(err.to_string())); 46 | } 47 | } 48 | 49 | pub fn get_account() -> Option { 50 | let account: i64 = load_sql_config(&"account") 51 | .ok() 52 | .flatten() 53 | .and_then(|v| v.parse().ok())?; 54 | 55 | let token = load_sql_config(&"token").ok().flatten()?; 56 | let token = Self::base64_to_token(&token); 57 | 58 | Some(Self { account, token }) 59 | } 60 | } 61 | 62 | pub async fn token_login(token: Token, sender: &Sender, client: &Client) { 63 | match client.token_login(token).await { 64 | Ok(resp) => sender.send(LoginRespond(resp.into())), 65 | Err(err) => sender.send(LoginFailed(err.to_string())), 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/app/main/chatroom/message_group.rs: -------------------------------------------------------------------------------- 1 | use relm4::factory::{DynamicIndex, FactoryComponent}; 2 | use relm4::{adw, gtk, Sender, WidgetPlus}; 3 | 4 | use adw::{prelude::*, Avatar}; 5 | use gtk::gdk_pixbuf::Pixbuf; 6 | use gtk::{Align, Box, Label, Orientation, Picture, Widget}; 7 | use tokio::task; 8 | 9 | use crate::db::fs::{download_user_avatar_file, get_user_avatar_path}; 10 | use crate::handler::ACCOUNT; 11 | use crate::utils::message::{Content, Message}; 12 | 13 | use super::ChatroomMsg; 14 | 15 | #[derive(Debug, Clone)] 16 | pub(crate) struct MessageGroup { 17 | pub sender_id: i64, 18 | pub sender_name: String, 19 | pub messages: Vec, 20 | } 21 | 22 | impl FactoryComponent for MessageGroup { 23 | type Widgets = (); 24 | type Input = ChatroomMsg; 25 | type Root = Box; 26 | type Command = (); 27 | type CommandOutput = (); 28 | type InitParams = MessageGroup; 29 | type Output = (); 30 | 31 | fn init_model( 32 | message: Self::InitParams, 33 | _index: &DynamicIndex, 34 | _input: &Sender, 35 | _output: &Sender, 36 | ) -> MessageGroup { 37 | message 38 | } 39 | 40 | fn init_root(&self) -> Self::Root { 41 | let root_box = Box::builder() 42 | .orientation(Orientation::Horizontal) 43 | .spacing(8) 44 | .margin_bottom(8) 45 | .build(); 46 | 47 | if &self.sender_id == ACCOUNT.get().unwrap() { 48 | root_box.set_halign(Align::End) 49 | } 50 | 51 | root_box 52 | } 53 | 54 | fn init_widgets( 55 | &mut self, 56 | _index: &DynamicIndex, 57 | root: &Self::Root, 58 | _returned_widget: &Widget, 59 | _input: &Sender, 60 | _output: &Sender, 61 | ) -> Self::Widgets { 62 | let message_alignment = if &self.sender_id == ACCOUNT.get().unwrap() { 63 | Align::End 64 | } else { 65 | Align::Start 66 | }; 67 | 68 | relm4::view! { 69 | avatar_box = Box { 70 | set_orientation: Orientation::Vertical, 71 | #[name = "avatar"] 72 | Avatar { 73 | set_size: 32, 74 | set_text: Some(self.sender_name.as_str()), 75 | set_show_initials: true 76 | } 77 | } 78 | } 79 | 80 | let avatar_path = get_user_avatar_path(self.sender_id); 81 | if avatar_path.exists() { 82 | if let Ok(pixbuf) = Pixbuf::from_file_at_size(avatar_path, 32, 32) { 83 | let image = Picture::for_pixbuf(&pixbuf); 84 | if let Some(paintable) = image.paintable() { 85 | avatar.set_custom_image(Some(&paintable)); 86 | } 87 | } 88 | } else { 89 | task::spawn(download_user_avatar_file(self.sender_id)); 90 | } 91 | 92 | relm4::view! { 93 | main_box = Box { 94 | set_orientation: Orientation::Vertical, 95 | set_spacing: 4, 96 | #[name = "username_label"] 97 | Label { 98 | set_label: &self.sender_name, 99 | set_css_classes: &["caption"] 100 | }, 101 | #[name = "messages_box"] 102 | Box { 103 | set_orientation: Orientation::Vertical, 104 | } 105 | } 106 | } 107 | 108 | for message in self.messages.iter() { 109 | // let label = message.text(); 110 | relm4::view! { 111 | message_box = Box { 112 | set_css_classes: &["card", "message-box"], 113 | set_halign: message_alignment, 114 | set_margin_all: 2, 115 | #[name = "inner_message_box"] 116 | Box { 117 | set_css_classes: &["inner-message-box"], 118 | set_margin_all: 8, 119 | } 120 | } 121 | } 122 | for content in message.contents.clone() { 123 | match content { 124 | Content::Text(text) => { 125 | let label = Label::builder().label(&text).selectable(true).build(); 126 | inner_message_box.append(&label) 127 | } 128 | Content::Image { 129 | url: _, 130 | filename: _, 131 | } => { 132 | let label = Label::new(Some("[图片]")); 133 | inner_message_box.append(&label) 134 | } 135 | } 136 | } 137 | messages_box.append(&message_box); 138 | } 139 | 140 | if &self.sender_id == ACCOUNT.get().unwrap() { 141 | username_label.set_halign(Align::End); 142 | root.append(&main_box); 143 | root.append(&avatar_box); 144 | } else { 145 | username_label.set_halign(Align::Start); 146 | root.append(&avatar_box); 147 | root.append(&main_box); 148 | } 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /src/app/main/chatroom/mod.rs: -------------------------------------------------------------------------------- 1 | mod message_group; 2 | 3 | use std::collections::VecDeque; 4 | 5 | use relm4::factory::{DynamicIndex, FactoryComponent, FactoryVecDeque}; 6 | use relm4::{adw, gtk, Sender, WidgetPlus}; 7 | 8 | use adw::prelude::*; 9 | use gtk::{Box, Button, Entry, Orientation, ScrolledWindow, Stack, StackPage}; 10 | use ricq::msg::{elem, MessageChain}; 11 | use tokio::task; 12 | 13 | use crate::db::sql::get_friend_remark; 14 | use crate::handler::{ACCOUNT, CLIENT}; 15 | use crate::utils::message::{Content, Message}; 16 | 17 | use super::MainMsg; 18 | use message_group::MessageGroup; 19 | 20 | #[derive(Debug)] 21 | pub(crate) struct Chatroom { 22 | pub account: i64, 23 | /// 群组/好友 24 | pub is_group: bool, 25 | pub messages: FactoryVecDeque, 26 | input_box: Box, 27 | } 28 | 29 | impl Chatroom { 30 | pub(crate) fn push_message(&mut self, message: Message) { 31 | if self.messages.is_empty() { 32 | self.messages.push_back(MessageGroup { 33 | sender_id: message.sender_id, 34 | sender_name: message.sender_name.clone(), 35 | messages: vec![message], 36 | }); 37 | } else { 38 | let mut last_message_group = self.messages.pop_back().unwrap(); 39 | if last_message_group.sender_id == message.sender_id { 40 | last_message_group.messages.push(message); 41 | self.messages.push_back(last_message_group); 42 | } else { 43 | self.messages.push_back(last_message_group); 44 | self.messages.push_back(MessageGroup { 45 | sender_id: message.sender_id, 46 | sender_name: message.sender_name.clone(), 47 | messages: vec![message], 48 | }); 49 | } 50 | } 51 | 52 | self.messages.render_changes(); 53 | } 54 | } 55 | 56 | async fn send_message(target: i64, is_group: bool, content: String, output: Sender) { 57 | let client = CLIENT.get().unwrap(); 58 | let message = MessageChain::new(elem::Text::new(content.clone())); 59 | let self_account = *ACCOUNT.get().unwrap(); 60 | if is_group { 61 | match client.send_group_message(target, message).await { 62 | Ok(_) => output.send(MainMsg::GroupMessage { 63 | group_id: target, 64 | message: Message { 65 | sender_id: self_account, 66 | sender_name: get_friend_remark(self_account), 67 | contents: vec![Content::Text(content)], 68 | }, 69 | }), 70 | Err(err) => panic!("err: {:?}", err), 71 | } 72 | } else { 73 | match client.send_friend_message(target, message).await { 74 | Ok(_) => output.send(MainMsg::FriendMessage { 75 | friend_id: target, 76 | message: Message { 77 | sender_id: self_account, 78 | sender_name: get_friend_remark(self_account), 79 | contents: vec![Content::Text(content)], 80 | }, 81 | }), 82 | Err(err) => panic!("err: {:?}", err), 83 | } 84 | }; 85 | } 86 | 87 | #[derive(Debug)] 88 | pub(crate) enum ChatroomMsg { 89 | AddMessage(Message), 90 | SendMessage(String), 91 | } 92 | 93 | pub(crate) struct ChatroomInitParams { 94 | pub account: i64, 95 | pub is_group: bool, 96 | pub messages: VecDeque, 97 | } 98 | 99 | impl FactoryComponent for Chatroom { 100 | type Widgets = (); 101 | type Input = ChatroomMsg; 102 | type Root = Box; 103 | type Command = (); 104 | type CommandOutput = (); 105 | type Output = MainMsg; 106 | type InitParams = ChatroomInitParams; 107 | 108 | fn init_root(&self) -> Self::Root { 109 | let root = Box::new(Orientation::Vertical, 0); 110 | 111 | relm4::view! { 112 | view = &ScrolledWindow { 113 | set_vexpand: true, 114 | set_hexpand: true, 115 | set_child: Some(self.messages.widget()) 116 | } 117 | } 118 | 119 | root.append(&view); 120 | root.append(&self.input_box); 121 | root 122 | } 123 | 124 | fn init_widgets( 125 | &mut self, 126 | _index: &DynamicIndex, 127 | _root: &Self::Root, 128 | returned_widget: &StackPage, 129 | _input: &Sender, 130 | _output: &Sender, 131 | ) -> Self::Widgets { 132 | let title = &format!( 133 | "{} {}", 134 | self.account, 135 | if self.is_group { "group" } else { "friend" } 136 | ); 137 | returned_widget.set_name(title); 138 | returned_widget.set_title(title); 139 | } 140 | 141 | fn init_model( 142 | init_params: Self::InitParams, 143 | _index: &DynamicIndex, 144 | input: &Sender, 145 | _output: &Sender, 146 | ) -> Self { 147 | let ChatroomInitParams { 148 | account, 149 | is_group, 150 | messages: messages_src, 151 | } = init_params; 152 | let messages_box = Box::new(Orientation::Vertical, 2); 153 | messages_box.set_css_classes(&["chatroom-box"]); 154 | 155 | let messages: FactoryVecDeque = 156 | FactoryVecDeque::new(messages_box, input); 157 | for msg_src in messages_src.iter() { 158 | input.send(ChatroomMsg::AddMessage(msg_src.clone())) 159 | } 160 | 161 | relm4::view! { 162 | entry = &Entry { 163 | set_hexpand: true, 164 | set_show_emoji_icon: true, 165 | set_placeholder_text: Some("Send a message..."), 166 | set_margin_end: 8, 167 | connect_activate[input] => move |e| { 168 | if !e.buffer().text().is_empty(){ 169 | input.send(ChatroomMsg::SendMessage( 170 | e.buffer().text() 171 | )); 172 | e.buffer().set_text(""); 173 | } 174 | } 175 | } 176 | } 177 | 178 | let entry_buffer = entry.buffer(); 179 | 180 | relm4::view! { 181 | input_box = &Box { 182 | set_margin_all: 8, 183 | append: &entry, 184 | Button { 185 | set_icon_name: "send-symbolic", 186 | connect_clicked[input] => move |_| { 187 | input.send(ChatroomMsg::SendMessage( 188 | entry_buffer.text() 189 | )); 190 | entry_buffer.set_text(""); 191 | } 192 | }, 193 | } 194 | } 195 | 196 | Chatroom { 197 | account, 198 | is_group, 199 | messages, 200 | input_box, 201 | } 202 | } 203 | 204 | fn update( 205 | &mut self, 206 | relm_msg: Self::Input, 207 | _input: &Sender, 208 | output: &Sender, 209 | ) -> Option { 210 | match relm_msg { 211 | ChatroomMsg::AddMessage(message) => self.push_message(message), 212 | ChatroomMsg::SendMessage(content) => { 213 | task::spawn(send_message( 214 | self.account, 215 | self.is_group, 216 | content, 217 | output.clone(), 218 | )); 219 | } 220 | } 221 | None 222 | } 223 | 224 | fn output_to_parent_msg(output: Self::Output) -> Option { 225 | Some(output) 226 | } 227 | } 228 | -------------------------------------------------------------------------------- /src/app/main/mod.rs: -------------------------------------------------------------------------------- 1 | mod chatroom; 2 | mod sidebar; 3 | 4 | use std::collections::VecDeque; 5 | 6 | use once_cell::sync::OnceCell; 7 | use relm4::factory::FactoryVecDeque; 8 | use relm4::{ 9 | adw, component::Controller, gtk, Component, ComponentController, ComponentParts, 10 | ComponentSender, 11 | }; 12 | 13 | use adw::{prelude::*, HeaderBar, Leaflet, Toast, ToastOverlay}; 14 | use gtk::{Align, Box, Label, MenuButton, Orientation, Separator, Stack}; 15 | 16 | use chatroom::{Chatroom, ChatroomInitParams}; 17 | use sidebar::{SidebarModel, SidebarMsg}; 18 | 19 | use crate::db::sql::{get_db, get_group_name}; 20 | use crate::utils::message::Message; 21 | 22 | pub(crate) static MAIN_SENDER: OnceCell> = OnceCell::new(); 23 | 24 | #[derive(Debug)] 25 | pub(crate) struct MainPageModel { 26 | sidebar: Controller, 27 | chatrooms: FactoryVecDeque, 28 | } 29 | 30 | impl MainPageModel { 31 | fn is_item_in_list(&self, account: i64, is_group: bool) -> bool { 32 | for i in 0..self.chatrooms.len() { 33 | let chatroom = self.chatrooms.get(i); 34 | if chatroom.account == account && chatroom.is_group == is_group { 35 | return true; 36 | } 37 | } 38 | 39 | false 40 | } 41 | 42 | fn insert_chatroom(&mut self, account: i64, is_group: bool) { 43 | // TODO: Get history messages 44 | let messages = VecDeque::new(); 45 | self.chatrooms.push_front(ChatroomInitParams { 46 | account, 47 | is_group, 48 | messages, 49 | }); 50 | 51 | self.chatrooms.render_changes(); 52 | } 53 | 54 | fn push_friend_message(&mut self, friend_id: i64, message: Message) { 55 | for i in 0..self.chatrooms.len() { 56 | let mut chatroom = self.chatrooms.get_mut(i); 57 | if chatroom.account == friend_id && !chatroom.is_group { 58 | chatroom.push_message(message); 59 | break; 60 | } 61 | } 62 | } 63 | 64 | fn push_group_message(&mut self, group_id: i64, message: Message) { 65 | for i in 0..self.chatrooms.len() { 66 | let mut chatroom = self.chatrooms.get_mut(i); 67 | if chatroom.account == group_id && chatroom.is_group { 68 | chatroom.push_message(message); 69 | break; 70 | } 71 | } 72 | } 73 | } 74 | 75 | #[derive(Debug)] 76 | pub(crate) enum MainMsg { 77 | WindowFolded, 78 | GroupMessage { group_id: i64, message: Message }, 79 | FriendMessage { friend_id: i64, message: Message }, 80 | SelectChatroom(i64, bool), 81 | PushToast(String), 82 | } 83 | 84 | pub struct MainPageWidgets { 85 | root: ToastOverlay, 86 | main_page: Leaflet, 87 | chatroom: Box, 88 | chatroom_title: Label, 89 | chatroom_subtitle: Label, 90 | chatroom_stack: Stack, 91 | } 92 | 93 | relm4::new_action_group!(WindowActionGroup, "menu"); 94 | relm4::new_stateless_action!(ShortcutsAction, WindowActionGroup, "shortcuts"); 95 | relm4::new_stateless_action!(AboutAction, WindowActionGroup, "about"); 96 | 97 | impl Component for MainPageModel { 98 | type Input = MainMsg; 99 | type Output = (); 100 | type Widgets = MainPageWidgets; 101 | type InitParams = (); 102 | type Root = ToastOverlay; 103 | type CommandOutput = (); 104 | 105 | fn init_root() -> Self::Root { 106 | ToastOverlay::new() 107 | } 108 | 109 | fn init( 110 | _init_params: Self::InitParams, 111 | root: &Self::Root, 112 | sender: &ComponentSender, 113 | ) -> ComponentParts { 114 | MAIN_SENDER 115 | .set(sender.clone()) 116 | .expect("failed to initialize main sender"); 117 | 118 | let sidebar_controller = SidebarModel::builder() 119 | .launch(()) 120 | .forward(&sender.input, |message| message); 121 | 122 | relm4::menu! { 123 | main_menu: { 124 | "Keyboard Shortcuts" => ShortcutsAction, 125 | "About Gtk QQ" => AboutAction 126 | } 127 | } 128 | 129 | relm4::view! { 130 | #[name = "main_page"] 131 | Leaflet { 132 | append: sidebar_controller.widget(), 133 | append = &Separator::new(Orientation::Horizontal), 134 | #[name = "chatroom"] 135 | append = &Box { 136 | set_vexpand: true, 137 | set_hexpand: true, 138 | set_orientation: Orientation::Vertical, 139 | HeaderBar { 140 | set_title_widget = Some(&Box) { 141 | set_orientation: Orientation::Vertical, 142 | set_valign: Align::Center, 143 | #[name = "chatroom_title"] 144 | Label { 145 | set_label: "Chatroom" 146 | }, 147 | #[name = "chatroom_subtitle"] 148 | Label { 149 | set_css_classes: &["subtitle"], 150 | set_label: "Chatroom" 151 | }, 152 | }, 153 | pack_end = &MenuButton { 154 | set_icon_name: "menu-symbolic", 155 | set_menu_model: Some(&main_menu), 156 | } 157 | }, 158 | append: chatroom_stack = &Stack {}, 159 | }, 160 | connect_folded_notify[sender] => move |leaflet| { 161 | if leaflet.is_folded() { 162 | sender.input(MainMsg::WindowFolded); 163 | } 164 | }, 165 | } 166 | } 167 | 168 | root.set_child(Some(&main_page)); 169 | 170 | let chatrooms: FactoryVecDeque = 171 | FactoryVecDeque::new(chatroom_stack.clone(), &sender.input); 172 | 173 | ComponentParts { 174 | model: MainPageModel { 175 | sidebar: sidebar_controller, 176 | chatrooms, 177 | }, 178 | widgets: MainPageWidgets { 179 | root: root.clone(), 180 | main_page, 181 | chatroom, 182 | chatroom_title, 183 | chatroom_subtitle, 184 | chatroom_stack, 185 | }, 186 | } 187 | } 188 | 189 | fn update_with_view( 190 | &mut self, 191 | widgets: &mut Self::Widgets, 192 | msg: Self::Input, 193 | _sender: &ComponentSender, 194 | ) { 195 | use MainMsg::*; 196 | match msg { 197 | WindowFolded => { 198 | widgets.main_page.set_visible_child(&widgets.chatroom); 199 | } 200 | SelectChatroom(account, is_group) => { 201 | if !self.is_item_in_list(account, is_group) { 202 | // TODO: Get last_message from history or some other places 203 | self.sidebar.sender().send(SidebarMsg::InsertChatItem( 204 | account, 205 | is_group, 206 | String::new(), 207 | )); 208 | self.insert_chatroom(account, is_group); 209 | } 210 | 211 | let child_name = 212 | &format!("{} {}", account, if is_group { "group" } else { "friend" }); 213 | widgets.chatroom_stack.set_visible_child_name(child_name); 214 | 215 | if is_group { 216 | let group_name: String = get_group_name(account); 217 | let title = group_name; 218 | let subtitle = account.to_string(); 219 | widgets.chatroom_title.set_label(&title); 220 | widgets.chatroom_subtitle.set_label(&subtitle); 221 | } else { 222 | let (user_name, user_remark): (String, String) = get_db() 223 | .query_row( 224 | "Select name, remark from friends where id=?1", 225 | [account], 226 | |row| Ok((row.get(0).unwrap(), row.get(1).unwrap())), 227 | ) 228 | .unwrap(); 229 | let title = &user_name; 230 | let subtitle = format!("{} ({})", user_remark, account); 231 | widgets.chatroom_title.set_label(title); 232 | widgets.chatroom_subtitle.set_label(&subtitle); 233 | } 234 | } 235 | FriendMessage { friend_id, message } => { 236 | use SidebarMsg::*; 237 | if self.is_item_in_list(friend_id, false) { 238 | self.sidebar 239 | .sender() 240 | .send(UpdateChatItem(friend_id, false, message.text())); 241 | } else { 242 | self.sidebar 243 | .sender() 244 | .send(InsertChatItem(friend_id, false, message.text())); 245 | self.insert_chatroom(friend_id, false); 246 | // 当所插入的 chatroom 为唯一的一个 chatroom 时,将其设为焦点, 247 | // 以触发自动更新 chatroom 的标题与副标题。 248 | if self.chatrooms.len() == 1 { 249 | let child_name = &format!("{} friend", friend_id); 250 | widgets.chatroom_stack.set_visible_child_name(child_name); 251 | 252 | let (user_name, user_remark): (String, String) = get_db() 253 | .query_row( 254 | "Select name, remark from friends where id=?1", 255 | [friend_id], 256 | |row| Ok((row.get(0).unwrap(), row.get(1).unwrap())), 257 | ) 258 | .unwrap(); 259 | let title = &user_name; 260 | let subtitle = format!("{} ({})", user_remark, friend_id); 261 | widgets.chatroom_title.set_label(title); 262 | widgets.chatroom_subtitle.set_label(&subtitle); 263 | } 264 | } 265 | 266 | self.push_friend_message(friend_id, message); 267 | } 268 | GroupMessage { group_id, message } => { 269 | use SidebarMsg::*; 270 | if self.is_item_in_list(group_id, true) { 271 | self.sidebar 272 | .sender() 273 | .send(UpdateChatItem(group_id, true, message.text())); 274 | } else { 275 | self.sidebar 276 | .sender() 277 | .send(InsertChatItem(group_id, true, message.text())); 278 | self.insert_chatroom(group_id, true); 279 | // 当所插入的 chatroom 为唯一的一个 chatroom 时,将其设为焦点, 280 | // 以触发自动更新 chatroom 的标题与副标题。 281 | if self.chatrooms.len() == 1 { 282 | let child_name = &format!("{} group", group_id); 283 | widgets.chatroom_stack.set_visible_child_name(child_name); 284 | 285 | let group_name: String = get_group_name(group_id); 286 | let title = group_name; 287 | let subtitle = group_id.to_string(); 288 | widgets.chatroom_title.set_label(&title); 289 | widgets.chatroom_subtitle.set_label(&subtitle); 290 | } 291 | } 292 | 293 | self.push_group_message(group_id, message); 294 | } 295 | PushToast(content) => { 296 | widgets.root.add_toast(&Toast::new(&content)); 297 | } 298 | } 299 | } 300 | } 301 | -------------------------------------------------------------------------------- /src/app/main/sidebar/chats/chat_item.rs: -------------------------------------------------------------------------------- 1 | use relm4::factory::{DynamicIndex, FactoryComponent}; 2 | use relm4::{adw, gtk, Sender}; 3 | 4 | use adw::{prelude::*, Avatar}; 5 | use gtk::gdk_pixbuf::Pixbuf; 6 | use gtk::pango::EllipsizeMode; 7 | use gtk::{Align, Box, Label, ListBox, ListBoxRow, Orientation, Picture}; 8 | 9 | use tokio::task; 10 | 11 | use crate::db::{ 12 | fs::{ 13 | download_group_avatar_file, download_user_avatar_file, get_group_avatar_path, 14 | get_user_avatar_path, 15 | }, 16 | sql::{get_friend_remark, get_group_name}, 17 | }; 18 | 19 | use super::ChatsMsg; 20 | 21 | #[derive(Debug)] 22 | pub struct ChatItem { 23 | pub account: i64, 24 | pub name: String, 25 | pub is_group: bool, 26 | pub last_message: String, 27 | } 28 | 29 | pub struct ChatItemWidgets { 30 | pub last_message: Label, 31 | } 32 | 33 | impl FactoryComponent for ChatItem { 34 | type InitParams = (i64, bool, String); 35 | type Widgets = ChatItemWidgets; 36 | type Input = (); 37 | type Output = (); 38 | type Command = (); 39 | type CommandOutput = (); 40 | type Root = Box; 41 | 42 | fn init_root(&self) -> Self::Root { 43 | relm4::view! { 44 | root = Box { 45 | set_margin_top: 8, 46 | set_margin_bottom: 8, 47 | } 48 | } 49 | 50 | root 51 | } 52 | 53 | fn init_widgets( 54 | &mut self, 55 | _index: &DynamicIndex, 56 | root: &Self::Root, 57 | _returned_widget: &ListBoxRow, 58 | _input: &Sender, 59 | _output: &Sender, 60 | ) -> Self::Widgets { 61 | relm4::view! { 62 | #[name = "avatar"] 63 | Avatar { 64 | set_text: Some(&self.name), 65 | set_show_initials: true, 66 | set_size: 48, 67 | set_margin_end: 8 68 | } 69 | }; 70 | 71 | if self.is_group { 72 | let avatar_path = get_group_avatar_path(self.account); 73 | if avatar_path.exists() { 74 | if let Ok(pixbuf) = Pixbuf::from_file_at_size(avatar_path, 48, 48) { 75 | let image = Picture::for_pixbuf(&pixbuf); 76 | if let Some(paintable) = image.paintable() { 77 | avatar.set_custom_image(Some(&paintable)); 78 | } 79 | } 80 | } else { 81 | task::spawn(download_group_avatar_file(self.account)); 82 | } 83 | } else { 84 | let avatar_path = get_user_avatar_path(self.account); 85 | if avatar_path.exists() { 86 | if let Ok(pixbuf) = Pixbuf::from_file_at_size(avatar_path, 48, 48) { 87 | let image = Picture::for_pixbuf(&pixbuf); 88 | if let Some(paintable) = image.paintable() { 89 | avatar.set_custom_image(Some(&paintable)); 90 | } 91 | } 92 | } else { 93 | task::spawn(download_user_avatar_file(self.account)); 94 | } 95 | } 96 | 97 | relm4::view! { 98 | #[name = "info"] 99 | Box { 100 | set_orientation: Orientation::Vertical, 101 | set_halign: Align::Start, 102 | set_spacing: 8, 103 | Label { 104 | set_xalign: 0.0, 105 | set_text: self.name.as_str(), 106 | set_ellipsize: EllipsizeMode::End, 107 | add_css_class: "heading" 108 | }, 109 | #[name = "last_message"] 110 | Label { 111 | set_text: self.last_message.as_str(), 112 | set_ellipsize: EllipsizeMode::End, 113 | add_css_class: "caption", 114 | set_xalign: 0.0, 115 | } 116 | } 117 | }; 118 | 119 | root.append(&avatar); 120 | root.append(&info); 121 | 122 | ChatItemWidgets { last_message } 123 | } 124 | 125 | fn init_model( 126 | init_params: Self::InitParams, 127 | _index: &DynamicIndex, 128 | _input: &Sender, 129 | _output: &Sender, 130 | ) -> Self { 131 | let (account, is_group, last_message) = init_params; 132 | let last_message = last_message.replace('\n', " "); 133 | let name = if is_group { 134 | get_group_name(account) 135 | } else { 136 | get_friend_remark(account) 137 | }; 138 | ChatItem { 139 | account, 140 | is_group, 141 | name, 142 | last_message, 143 | } 144 | } 145 | 146 | fn update_view( 147 | &self, 148 | widgets: &mut Self::Widgets, 149 | _input: &Sender, 150 | _output: &Sender, 151 | ) { 152 | widgets.last_message.set_label(&self.last_message); 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /src/app/main/sidebar/chats/mod.rs: -------------------------------------------------------------------------------- 1 | mod chat_item; 2 | 3 | use relm4::factory::FactoryVecDeque; 4 | use relm4::{adw, gtk, ComponentParts, ComponentSender, SimpleComponent}; 5 | 6 | use adw::prelude::*; 7 | use gtk::{ListBox, ScrolledWindow}; 8 | 9 | use super::SidebarMsg; 10 | use chat_item::ChatItem; 11 | 12 | #[derive(Debug)] 13 | pub struct ChatsModel { 14 | chats_list: FactoryVecDeque, 15 | } 16 | 17 | impl ChatsModel { 18 | fn update_chat_item(&mut self, account: i64, is_group: bool, last_message: String) { 19 | for i in 0..self.chats_list.len() { 20 | let this_account = self.chats_list.get(i).account; 21 | let is_this_group = self.chats_list.get(i).is_group; 22 | if this_account == account && is_this_group == is_group { 23 | self.chats_list.swap(0, i); 24 | self.chats_list.front_mut().unwrap().last_message = last_message; 25 | break; 26 | } 27 | } 28 | self.chats_list.render_changes(); 29 | } 30 | 31 | fn insert_chat_item(&mut self, account: i64, is_group: bool, last_message: String) { 32 | self.chats_list 33 | .push_front((account, is_group, last_message)); 34 | self.chats_list.render_changes(); 35 | } 36 | } 37 | 38 | #[derive(Debug)] 39 | pub enum ChatsMsg { 40 | SelectChatroom(i32), 41 | UpdateChatItem(i64, bool, String), 42 | InsertChatItem(i64, bool, String), 43 | } 44 | 45 | #[relm4::component(pub)] 46 | impl SimpleComponent for ChatsModel { 47 | type Input = ChatsMsg; 48 | type Output = SidebarMsg; 49 | type Widgets = ChatsWidgets; 50 | type InitParams = (); 51 | 52 | view! { 53 | #[root] 54 | chats = ScrolledWindow { 55 | set_child: sidebar_chats = Some(&ListBox) { 56 | set_css_classes: &["navigation-sidebar"], 57 | connect_row_activated[sender] => move |_, selected_row| { 58 | let index = selected_row.index(); 59 | sender.input(ChatsMsg::SelectChatroom(index)); 60 | }, 61 | } 62 | } 63 | } 64 | 65 | fn init( 66 | _init_params: (), 67 | root: &Self::Root, 68 | sender: &ComponentSender, 69 | ) -> ComponentParts { 70 | let widgets = view_output!(); 71 | 72 | let chats_list: FactoryVecDeque = 73 | FactoryVecDeque::new(widgets.sidebar_chats.clone(), &sender.input); 74 | 75 | let model = ChatsModel { chats_list }; 76 | 77 | ComponentParts { model, widgets } 78 | } 79 | 80 | fn update(&mut self, msg: ChatsMsg, sender: &ComponentSender) { 81 | use ChatsMsg::*; 82 | match msg { 83 | SelectChatroom(index) => { 84 | let chat_item = self.chats_list.get(index as usize); 85 | let account = chat_item.account; 86 | let is_group = chat_item.is_group; 87 | sender.output(SidebarMsg::SelectChatroom(account, is_group)); 88 | } 89 | UpdateChatItem(account, is_group, last_message) => { 90 | self.update_chat_item(account, is_group, last_message) 91 | } 92 | InsertChatItem(account, is_group, last_message) => { 93 | self.insert_chat_item(account, is_group, last_message) 94 | } 95 | } 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/app/main/sidebar/contact/friends/friends_group.rs: -------------------------------------------------------------------------------- 1 | use relm4::factory::{DynamicIndex, FactoryComponent}; 2 | use relm4::{adw, gtk, Sender, WidgetPlus}; 3 | 4 | use adw::{prelude::*, Avatar, ExpanderRow}; 5 | use gtk::gdk_pixbuf::Pixbuf; 6 | use gtk::glib::clone; 7 | use gtk::pango::EllipsizeMode; 8 | use gtk::{Align, Box, GestureClick, Label, Orientation, Picture, Widget}; 9 | 10 | use tokio::task; 11 | 12 | use super::FriendsMsg; 13 | use crate::db::fs::{download_user_avatar_file, get_user_avatar_path}; 14 | use crate::db::sql::Friend; 15 | 16 | pub enum FriendsGroupMessage { 17 | SelectUser(i64), 18 | } 19 | 20 | #[derive(Debug, Clone)] 21 | pub struct FriendsGroup { 22 | pub id: u8, 23 | pub name: String, 24 | pub online_friends: i32, 25 | pub friends: Vec, 26 | } 27 | 28 | impl FactoryComponent for FriendsGroup { 29 | type InitParams = FriendsGroup; 30 | type Widgets = (); 31 | type Input = FriendsGroupMessage; 32 | type Output = FriendsMsg; 33 | type Command = (); 34 | type CommandOutput = (); 35 | type Root = ExpanderRow; 36 | 37 | fn init_model( 38 | init_params: Self::InitParams, 39 | _index: &DynamicIndex, 40 | _input: &Sender, 41 | _output: &Sender, 42 | ) -> Self { 43 | init_params 44 | } 45 | 46 | fn init_root(&self) -> Self::Root { 47 | let subtitle = format!("{}/{}", self.online_friends, self.friends.len()); 48 | relm4::view! { 49 | group = ExpanderRow { 50 | set_width_request: 320, 51 | add_prefix = &Label { 52 | set_label: self.name.as_str() 53 | }, 54 | set_subtitle: &subtitle 55 | } 56 | } 57 | 58 | group 59 | } 60 | 61 | fn init_widgets( 62 | &mut self, 63 | _index: &DynamicIndex, 64 | group: &Self::Root, 65 | _returned_widget: &Widget, 66 | input: &Sender, 67 | _output: &Sender, 68 | ) -> Self::Widgets { 69 | let friends = self.friends.clone(); 70 | for friend in friends.into_iter() { 71 | // Create user item click event 72 | let gesture = GestureClick::new(); 73 | gesture.connect_released(clone!(@strong input => move |_, _, _, _| { 74 | input.send(FriendsGroupMessage::SelectUser(friend.id)); 75 | })); 76 | 77 | relm4::view! { 78 | child = Box { 79 | set_margin_all: 8, 80 | #[name = "avatar"] 81 | Avatar { 82 | set_text: Some(&friend.name), 83 | set_show_initials: true, 84 | set_size: 48, 85 | set_margin_end: 8 86 | }, 87 | Box { 88 | set_orientation: Orientation::Vertical, 89 | set_halign: Align::Start, 90 | set_spacing: 8, 91 | Label { 92 | set_xalign: 0.0, 93 | set_text: &friend.remark, 94 | add_css_class: "heading", 95 | set_ellipsize: EllipsizeMode::End, 96 | }, 97 | Label { 98 | set_text: &friend.name, 99 | add_css_class: "caption", 100 | set_xalign: 0.0, 101 | set_ellipsize: EllipsizeMode::End, 102 | }, 103 | }, 104 | add_controller: &gesture, 105 | } 106 | } 107 | 108 | let avatar_path = get_user_avatar_path(friend.id); 109 | if avatar_path.exists() { 110 | if let Ok(pixbuf) = Pixbuf::from_file_at_size(avatar_path, 48, 48) { 111 | let image = Picture::for_pixbuf(&pixbuf); 112 | if let Some(paintable) = image.paintable() { 113 | avatar.set_custom_image(Some(&paintable)); 114 | } 115 | } 116 | } else { 117 | task::spawn(download_user_avatar_file(friend.id)); 118 | } 119 | 120 | group.add_row(&child); 121 | } 122 | } 123 | 124 | fn update( 125 | &mut self, 126 | relm_msg: Self::Input, 127 | _input: &Sender, 128 | output: &Sender, 129 | ) -> Option { 130 | use FriendsGroupMessage::*; 131 | match relm_msg { 132 | SelectUser(account) => { 133 | output.send(FriendsMsg::SelectChatroom(account, false)); 134 | } 135 | } 136 | None 137 | } 138 | 139 | fn output_to_parent_msg(output: FriendsMsg) -> Option { 140 | Some(output) 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /src/app/main/sidebar/contact/friends/mod.rs: -------------------------------------------------------------------------------- 1 | mod friends_group; 2 | mod search_item; 3 | 4 | use tokio::task; 5 | 6 | use relm4::factory::FactoryVecDeque; 7 | use relm4::{adw, gtk, Component, ComponentParts, ComponentSender, WidgetPlus}; 8 | 9 | use adw::prelude::*; 10 | use gtk::{Box, Button, Entry, EntryIconPosition, ListBox, Orientation, ScrolledWindow}; 11 | 12 | use super::ContactMsg; 13 | use crate::db::sql::{get_db, refresh_friends_list, Friend}; 14 | use friends_group::FriendsGroup; 15 | 16 | #[derive(Debug)] 17 | pub struct FriendsModel { 18 | friends_list: Option>, 19 | search_list: Option>, 20 | is_refresh_button_enabled: bool, 21 | } 22 | 23 | impl FriendsModel { 24 | fn render_friends(&mut self) -> rusqlite::Result<()> { 25 | let friends_list = self.friends_list.as_mut().unwrap(); 26 | friends_list.clear(); 27 | 28 | let conn = get_db(); 29 | 30 | let mut stmt = conn.prepare("Select id, name, remark, group_id from friends")?; 31 | let friends: Vec = stmt 32 | .query_map([], |row| { 33 | Ok(Friend { 34 | id: row.get(0)?, 35 | name: row.get(1)?, 36 | remark: row.get(2)?, 37 | group_id: row.get(3)?, 38 | }) 39 | })? 40 | .map(|result| result.unwrap()) 41 | .collect(); 42 | 43 | let friends_groups: Vec = conn 44 | .prepare("Select id, name, online_friends from friends_groups")? 45 | .query_map([], |row| { 46 | Ok(FriendsGroup { 47 | id: row.get(0)?, 48 | name: row.get(1)?, 49 | online_friends: row.get(2)?, 50 | friends: friends 51 | .clone() 52 | .into_iter() 53 | .filter(|friend| friend.group_id == row.get(0).unwrap()) 54 | .collect(), 55 | }) 56 | })? 57 | .map(|result| result.unwrap()) 58 | .collect(); 59 | 60 | for friends_group in friends_groups { 61 | friends_list.push_back(friends_group); 62 | } 63 | 64 | friends_list.render_changes(); 65 | 66 | Ok(()) 67 | } 68 | 69 | fn render_search_result(&mut self, keyword: String) -> rusqlite::Result<()> { 70 | let search_list = self.search_list.as_mut().unwrap(); 71 | search_list.clear(); 72 | 73 | let keyword = keyword.to_lowercase(); 74 | let conn = get_db(); 75 | 76 | let mut stmt = conn.prepare("Select id, name, remark, group_id from friends")?; 77 | let eligible_friends: Vec = stmt 78 | .query_map([], |row| { 79 | Ok(Friend { 80 | id: row.get(0)?, 81 | name: row.get(1)?, 82 | remark: row.get(2)?, 83 | group_id: row.get(3)?, 84 | }) 85 | })? 86 | .map(|result| result.unwrap()) 87 | .filter(|friend| { 88 | let match_name = friend.name.to_lowercase().contains(&keyword); 89 | let match_remark = friend.remark.to_lowercase().contains(&keyword); 90 | 91 | match_name || match_remark 92 | }) 93 | .collect(); 94 | 95 | for friend in eligible_friends { 96 | search_list.push_back(friend); 97 | } 98 | 99 | search_list.render_changes(); 100 | 101 | Ok(()) 102 | } 103 | } 104 | 105 | async fn refresh_friends(sender: ComponentSender) { 106 | sender.output(ContactMsg::PushToast( 107 | "Start refreshing the friends list...".to_string(), 108 | )); 109 | match refresh_friends_list().await { 110 | Ok(_) => sender.input(FriendsMsg::Render), 111 | Err(err) => sender.output(ContactMsg::PushToast(err.to_string())), 112 | } 113 | } 114 | 115 | #[derive(Debug)] 116 | pub enum FriendsMsg { 117 | SelectChatroom(i64, bool), 118 | SelectSearchItem(i32), 119 | Search(String), 120 | Refresh, 121 | Render, 122 | } 123 | 124 | #[derive(Debug)] 125 | pub struct FriendsWidgets { 126 | friend_list: Box, 127 | search_list: ListBox, 128 | scrolled_window: ScrolledWindow, 129 | } 130 | 131 | impl Component for FriendsModel { 132 | type Input = FriendsMsg; 133 | type Output = ContactMsg; 134 | type Widgets = FriendsWidgets; 135 | type InitParams = (); 136 | type Root = Box; 137 | type CommandOutput = (); 138 | 139 | fn init_root() -> Box { 140 | Box::new(Orientation::Vertical, 0) 141 | } 142 | 143 | fn init( 144 | _init_params: (), 145 | root: &Self::Root, 146 | sender: &ComponentSender, 147 | ) -> ComponentParts { 148 | let mut model = FriendsModel { 149 | friends_list: None, 150 | search_list: None, 151 | is_refresh_button_enabled: true, 152 | }; 153 | 154 | relm4::view! { 155 | #[name = "header_bar"] 156 | Box { 157 | set_margin_all: 8, 158 | Button { 159 | #[watch] 160 | set_sensitive: model.is_refresh_button_enabled, 161 | set_tooltip_text: Some("Refresh friends list"), 162 | set_icon_name: "view-refresh-symbolic", 163 | set_margin_end: 8, 164 | connect_clicked[sender] => move |_| { 165 | sender.input(FriendsMsg::Refresh); 166 | }, 167 | }, 168 | #[name = "search_entry"] 169 | Entry { 170 | set_icon_from_icon_name: (EntryIconPosition::Secondary, Some("system-search-symbolic")), 171 | set_placeholder_text: Some("Search in friends..."), 172 | set_width_request: 320 - 3 * 8 - 32, 173 | connect_changed[sender] => move |entry| { 174 | let keywords = entry.buffer().text(); 175 | sender.input(FriendsMsg::Search(keywords)); 176 | }, 177 | }, 178 | }, 179 | #[name = "scrolled_window"] 180 | ScrolledWindow { 181 | set_child: Some(&friend_list) 182 | }, 183 | friend_list = Box { 184 | set_vexpand: true, 185 | set_orientation: Orientation::Vertical, 186 | }, 187 | search_list = ListBox { 188 | set_vexpand: true, 189 | set_css_classes: &["navigation-sidebar"], 190 | connect_row_activated[sender] => move |_, selected_row| { 191 | let index = selected_row.index(); 192 | sender.input(FriendsMsg::SelectSearchItem(index)); 193 | }, 194 | } 195 | } 196 | 197 | root.append(&header_bar); 198 | root.append(&scrolled_window); 199 | 200 | let friend_list_factory: FactoryVecDeque = 201 | FactoryVecDeque::new(friend_list.clone(), &sender.input); 202 | let search_list_factory: FactoryVecDeque = 203 | FactoryVecDeque::new(search_list.clone(), &sender.input); 204 | 205 | model.friends_list = Some(friend_list_factory); 206 | model.search_list = Some(search_list_factory); 207 | 208 | model.render_friends().unwrap(); 209 | 210 | ComponentParts { 211 | model, 212 | widgets: FriendsWidgets { 213 | friend_list, 214 | search_list, 215 | scrolled_window, 216 | }, 217 | } 218 | } 219 | 220 | fn update_with_view( 221 | &mut self, 222 | widgets: &mut Self::Widgets, 223 | msg: Self::Input, 224 | sender: &ComponentSender, 225 | ) { 226 | use FriendsMsg::*; 227 | match msg { 228 | SelectChatroom(account, is_group) => { 229 | sender.output(ContactMsg::SelectChatroom(account, is_group)); 230 | } 231 | SelectSearchItem(index) => { 232 | let account = self.search_list.as_ref().unwrap().get(index as usize).id; 233 | sender.input(SelectChatroom(account, false)); 234 | } 235 | Refresh => { 236 | self.is_refresh_button_enabled = false; 237 | task::spawn(refresh_friends(sender.clone())); 238 | } 239 | Render => { 240 | match self.render_friends() { 241 | Ok(_) => sender.output(ContactMsg::PushToast( 242 | "Refreshed the friends list.".to_string(), 243 | )), 244 | Err(err) => sender.output(ContactMsg::PushToast(err.to_string())), 245 | } 246 | self.is_refresh_button_enabled = true; 247 | } 248 | Search(keyword) => { 249 | if keyword.is_empty() { 250 | widgets 251 | .scrolled_window 252 | .set_child(Some(&widgets.friend_list)); 253 | } else { 254 | if let Err(err) = self.render_search_result(keyword) { 255 | sender.output(ContactMsg::PushToast(err.to_string())) 256 | } 257 | widgets 258 | .scrolled_window 259 | .set_child(Some(&widgets.search_list)); 260 | } 261 | } 262 | } 263 | } 264 | } 265 | -------------------------------------------------------------------------------- /src/app/main/sidebar/contact/friends/search_item.rs: -------------------------------------------------------------------------------- 1 | use relm4::factory::{DynamicIndex, FactoryComponent}; 2 | use relm4::{adw, gtk, Sender}; 3 | 4 | use adw::{prelude::*, Avatar}; 5 | use gtk::gdk_pixbuf::Pixbuf; 6 | use gtk::pango::EllipsizeMode; 7 | use gtk::{Align, Box, Label, ListBox, ListBoxRow, Orientation, Picture}; 8 | 9 | use tokio::task; 10 | 11 | use crate::db::fs::{download_user_avatar_file, get_user_avatar_path}; 12 | use crate::db::sql::Friend; 13 | 14 | use super::FriendsMsg; 15 | 16 | impl FactoryComponent for Friend { 17 | type InitParams = Friend; 18 | type Widgets = (); 19 | type Input = (); 20 | type Output = (); 21 | type Command = (); 22 | type CommandOutput = (); 23 | type Root = Box; 24 | 25 | fn init_model( 26 | init_params: Self::InitParams, 27 | _index: &DynamicIndex, 28 | _input: &Sender, 29 | _output: &Sender, 30 | ) -> Self { 31 | init_params 32 | } 33 | 34 | fn init_root(&self) -> Self::Root { 35 | Box::default() 36 | } 37 | 38 | fn init_widgets( 39 | &mut self, 40 | _index: &DynamicIndex, 41 | root: &Self::Root, 42 | _returned_widget: &ListBoxRow, 43 | _input: &Sender, 44 | _output: &Sender, 45 | ) -> Self::Widgets { 46 | relm4::view! { 47 | item = Box { 48 | set_margin_top: 8, 49 | set_margin_bottom: 8, 50 | #[name = "avatar"] 51 | Avatar { 52 | set_text: Some(&self.name), 53 | set_show_initials: true, 54 | set_size: 48, 55 | set_margin_end: 8 56 | }, 57 | Box { 58 | set_orientation: Orientation::Vertical, 59 | set_halign: Align::Start, 60 | set_spacing: 8, 61 | Label { 62 | set_xalign: 0.0, 63 | set_text: self.remark.as_str(), 64 | add_css_class: "heading", 65 | set_ellipsize: EllipsizeMode::End, 66 | }, 67 | Label { 68 | set_text: self.name.to_string().as_str(), 69 | add_css_class: "caption", 70 | set_xalign: 0.0, 71 | set_ellipsize: EllipsizeMode::End, 72 | }, 73 | }, 74 | } 75 | } 76 | 77 | let avatar_path = get_user_avatar_path(self.id); 78 | if avatar_path.exists() { 79 | if let Ok(pixbuf) = Pixbuf::from_file_at_size(avatar_path, 48, 48) { 80 | let image = Picture::for_pixbuf(&pixbuf); 81 | if let Some(paintable) = image.paintable() { 82 | avatar.set_custom_image(Some(&paintable)); 83 | } 84 | } 85 | } else { 86 | task::spawn(download_user_avatar_file(self.id)); 87 | } 88 | 89 | root.append(&item); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/app/main/sidebar/contact/groups/group_item.rs: -------------------------------------------------------------------------------- 1 | use relm4::factory::{DynamicIndex, FactoryComponent}; 2 | use relm4::{adw, gtk, Sender}; 3 | 4 | use adw::{prelude::*, Avatar}; 5 | use gtk::gdk_pixbuf::Pixbuf; 6 | use gtk::pango::EllipsizeMode; 7 | use gtk::{Align, Box, Label, ListBox, ListBoxRow, Orientation, Picture}; 8 | 9 | use tokio::task; 10 | 11 | use super::GroupsMsg; 12 | use crate::db::fs::{download_group_avatar_file, get_group_avatar_path}; 13 | use crate::db::sql::Group; 14 | 15 | impl FactoryComponent for Group { 16 | type InitParams = Group; 17 | type Widgets = (); 18 | type Input = (); 19 | type Output = (); 20 | type Command = (); 21 | type CommandOutput = (); 22 | type Root = Box; 23 | 24 | fn init_model( 25 | init_params: Self::InitParams, 26 | _index: &DynamicIndex, 27 | _input: &Sender, 28 | _output: &Sender, 29 | ) -> Self { 30 | init_params 31 | } 32 | 33 | fn init_root(&self) -> Self::Root { 34 | Box::default() 35 | } 36 | 37 | fn init_widgets( 38 | &mut self, 39 | _index: &DynamicIndex, 40 | root: &Self::Root, 41 | _returned_widget: &ListBoxRow, 42 | _input: &Sender, 43 | _output: &Sender, 44 | ) -> Self::Widgets { 45 | relm4::view! { 46 | item = Box { 47 | set_margin_top: 8, 48 | set_margin_bottom: 8, 49 | #[name = "avatar"] 50 | Avatar { 51 | set_text: Some(&self.name), 52 | set_show_initials: true, 53 | set_size: 48, 54 | set_margin_end: 8 55 | }, 56 | Box { 57 | set_orientation: Orientation::Vertical, 58 | set_halign: Align::Start, 59 | set_spacing: 8, 60 | Label { 61 | set_xalign: 0.0, 62 | set_text: self.name.as_str(), 63 | add_css_class: "heading", 64 | set_ellipsize: EllipsizeMode::End, 65 | }, 66 | Label { 67 | set_text: self.id.to_string().as_str(), 68 | add_css_class: "caption", 69 | set_xalign: 0.0, 70 | set_ellipsize: EllipsizeMode::End, 71 | }, 72 | }, 73 | } 74 | } 75 | 76 | let avatar_path = get_group_avatar_path(self.id); 77 | if avatar_path.exists() { 78 | if let Ok(pixbuf) = Pixbuf::from_file_at_size(avatar_path, 48, 48) { 79 | let image = Picture::for_pixbuf(&pixbuf); 80 | if let Some(paintable) = image.paintable() { 81 | avatar.set_custom_image(Some(&paintable)); 82 | } 83 | } 84 | } else { 85 | task::spawn(download_group_avatar_file(self.id)); 86 | } 87 | 88 | root.append(&item); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/app/main/sidebar/contact/groups/mod.rs: -------------------------------------------------------------------------------- 1 | mod group_item; 2 | 3 | use relm4::factory::FactoryVecDeque; 4 | use relm4::{adw, gtk, ComponentParts, ComponentSender, SimpleComponent, WidgetPlus}; 5 | 6 | use adw::prelude::*; 7 | use gtk::{Box, Button, Entry, EntryIconPosition, ListBox, Orientation, ScrolledWindow}; 8 | 9 | use tokio::task; 10 | 11 | use super::ContactMsg; 12 | use crate::db::sql::{get_db, refresh_groups_list, Group}; 13 | 14 | #[derive(Debug)] 15 | pub struct GroupsModel { 16 | group_list: Option>, 17 | is_refresh_button_enabled: bool, 18 | } 19 | 20 | impl GroupsModel { 21 | fn render_groups(&mut self) -> rusqlite::Result<()> { 22 | let group_list = self.group_list.as_mut().unwrap(); 23 | group_list.clear(); 24 | 25 | let conn = get_db(); 26 | 27 | let mut stmt = conn.prepare("Select id, name from groups order by name")?; 28 | let groups = stmt 29 | .query_map([], |row| { 30 | Ok(Group { 31 | id: row.get(0)?, 32 | name: row.get(1)?, 33 | }) 34 | })? 35 | .map(|result| result.unwrap()); 36 | 37 | for group in groups { 38 | group_list.push_back(group); 39 | } 40 | 41 | group_list.render_changes(); 42 | Ok(()) 43 | } 44 | 45 | fn search(&mut self, keyword: String) -> rusqlite::Result<()> { 46 | let group_list = self.group_list.as_mut().unwrap(); 47 | group_list.clear(); 48 | 49 | let conn = get_db(); 50 | 51 | let mut stmt = conn.prepare("Select id, name from groups")?; 52 | let groups = stmt 53 | .query_map([], |row| { 54 | Ok(Group { 55 | id: row.get(0)?, 56 | name: row.get(1)?, 57 | }) 58 | })? 59 | .map(|result| result.unwrap()); 60 | 61 | if keyword.is_empty() { 62 | for group in groups { 63 | group_list.push_back(group); 64 | } 65 | } else { 66 | let keyword = keyword.to_lowercase(); 67 | let groups = 68 | groups.filter(|group: &Group| group.name.to_lowercase().contains(&keyword)); 69 | for group in groups { 70 | group_list.push_back(group); 71 | } 72 | } 73 | 74 | group_list.render_changes(); 75 | 76 | Ok(()) 77 | } 78 | } 79 | 80 | async fn refresh_groups(sender: ComponentSender) { 81 | sender.output(ContactMsg::PushToast( 82 | "Start refreshing the groups list...".to_string(), 83 | )); 84 | match refresh_groups_list().await { 85 | Ok(_) => sender.input(GroupsMsg::Render), 86 | Err(err) => sender.output(ContactMsg::PushToast(err.to_string())), 87 | } 88 | } 89 | 90 | #[derive(Debug)] 91 | pub enum GroupsMsg { 92 | Refresh, 93 | Render, 94 | Search(String), 95 | Select(i32), 96 | } 97 | 98 | #[relm4::component(pub)] 99 | impl SimpleComponent for GroupsModel { 100 | type Input = GroupsMsg; 101 | type Output = ContactMsg; 102 | type Widgets = ContactWIdgets; 103 | type InitParams = (); 104 | 105 | view! { 106 | #[root] 107 | groups = Box { 108 | set_orientation: Orientation::Vertical, 109 | Box { 110 | set_margin_all: 8, 111 | Button { 112 | #[watch] 113 | set_sensitive: model.is_refresh_button_enabled, 114 | set_tooltip_text: Some("Refresh groups list"), 115 | set_icon_name: "view-refresh-symbolic", 116 | set_margin_end: 8, 117 | connect_clicked[sender] => move |_| { 118 | sender.input(GroupsMsg::Refresh); 119 | }, 120 | }, 121 | #[name = "search_entry"] 122 | Entry { 123 | set_icon_from_icon_name: (EntryIconPosition::Secondary, Some("system-search-symbolic")), 124 | set_placeholder_text: Some("Search in groups..."), 125 | set_width_request: 320 - 3 * 8 - 32, 126 | connect_changed[sender] => move |entry| { 127 | let keywords = entry.buffer().text(); 128 | sender.input(GroupsMsg::Search(keywords)); 129 | }, 130 | }, 131 | }, 132 | ScrolledWindow { 133 | set_child: groups_list = Some(&ListBox) { 134 | set_css_classes: &["navigation-sidebar"], 135 | set_vexpand: true, 136 | connect_row_activated[sender] => move |_, selected_row| { 137 | let index = selected_row.index(); 138 | sender.input(GroupsMsg::Select(index)); 139 | }, 140 | } 141 | } 142 | } 143 | } 144 | 145 | fn init( 146 | _init_params: (), 147 | root: &Self::Root, 148 | sender: &ComponentSender, 149 | ) -> ComponentParts { 150 | let mut model = GroupsModel { 151 | group_list: None, 152 | is_refresh_button_enabled: true, 153 | }; 154 | let widgets = view_output!(); 155 | 156 | let groups_list: FactoryVecDeque = 157 | FactoryVecDeque::new(widgets.groups_list.clone(), &sender.input); 158 | 159 | model.group_list = Some(groups_list); 160 | 161 | model.render_groups().unwrap(); 162 | 163 | ComponentParts { model, widgets } 164 | } 165 | 166 | fn update(&mut self, msg: GroupsMsg, sender: &ComponentSender) { 167 | use GroupsMsg::*; 168 | match msg { 169 | Select(index) => { 170 | let group_list = self.group_list.as_ref().unwrap(); 171 | let account = group_list.get(index as usize).id; 172 | sender.output(ContactMsg::SelectChatroom(account, true)); 173 | } 174 | Refresh => { 175 | self.is_refresh_button_enabled = false; 176 | task::spawn(refresh_groups(sender.clone())); 177 | } 178 | Render => { 179 | match self.render_groups() { 180 | Ok(_) => sender.output(ContactMsg::PushToast( 181 | "Refreshed the groups list.".to_string(), 182 | )), 183 | Err(err) => sender.output(ContactMsg::PushToast(err.to_string())), 184 | } 185 | self.is_refresh_button_enabled = true; 186 | } 187 | Search(keyword) => self.search(keyword).unwrap(), 188 | } 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /src/app/main/sidebar/contact/mod.rs: -------------------------------------------------------------------------------- 1 | mod friends; 2 | mod groups; 3 | 4 | use relm4::{ 5 | adw, component::Controller, gtk, Component, ComponentController, ComponentParts, 6 | ComponentSender, SimpleComponent, 7 | }; 8 | 9 | use adw::{prelude::*, ViewStack, ViewSwitcherBar}; 10 | use gtk::{Box, Orientation}; 11 | 12 | use self::groups::GroupsModel; 13 | use friends::FriendsModel; 14 | 15 | use super::SidebarMsg; 16 | 17 | #[derive(Debug)] 18 | pub struct ContactModel { 19 | friends: Controller, 20 | groups: Controller, 21 | } 22 | 23 | #[derive(Debug)] 24 | pub enum ContactMsg { 25 | SelectChatroom(i64, bool), 26 | PushToast(String), 27 | } 28 | 29 | #[relm4::component(pub)] 30 | impl SimpleComponent for ContactModel { 31 | type Input = ContactMsg; 32 | type Output = SidebarMsg; 33 | type Widgets = ContactWIdgets; 34 | type InitParams = (); 35 | 36 | view! { 37 | #[root] 38 | contact = Box { 39 | set_orientation: Orientation::Vertical, 40 | #[name = "contact_stack"] 41 | ViewStack { 42 | set_vexpand: true, 43 | }, 44 | ViewSwitcherBar { 45 | set_stack: Some(&contact_stack), 46 | set_reveal: true 47 | } 48 | } 49 | } 50 | 51 | fn init( 52 | _init_params: (), 53 | root: &Self::Root, 54 | sender: &ComponentSender, 55 | ) -> ComponentParts { 56 | let model = ContactModel { 57 | friends: FriendsModel::builder() 58 | .launch(()) 59 | .forward(&sender.input, |message| message), 60 | groups: GroupsModel::builder() 61 | .launch(()) 62 | .forward(&sender.input, |message| message), 63 | }; 64 | let widgets = view_output!(); 65 | 66 | let contact_stack: &ViewStack = &widgets.contact_stack; 67 | 68 | let friends = contact_stack.add_titled(model.friends.widget(), None, "Friends"); 69 | let groups = contact_stack.add_titled(model.groups.widget(), None, "Groups"); 70 | 71 | friends.set_icon_name(Some("person2-symbolic")); 72 | groups.set_icon_name(Some("people-symbolic")); 73 | 74 | ComponentParts { model, widgets } 75 | } 76 | 77 | fn update(&mut self, msg: ContactMsg, sender: &ComponentSender) { 78 | use ContactMsg::*; 79 | match msg { 80 | SelectChatroom(account, is_group) => { 81 | sender.output(SidebarMsg::SelectChatroom(account, is_group)); 82 | } 83 | PushToast(msg) => { 84 | sender.output(SidebarMsg::PushToast(msg)); 85 | } 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/app/main/sidebar/mod.rs: -------------------------------------------------------------------------------- 1 | mod chats; 2 | mod contact; 3 | 4 | use relm4::{ 5 | adw, component::Controller, gtk, Component, ComponentController, ComponentParts, 6 | ComponentSender, SimpleComponent, 7 | }; 8 | 9 | use adw::{prelude::*, HeaderBar, ViewStack, ViewSwitcherTitle}; 10 | use gtk::{Box, Orientation}; 11 | 12 | use super::MainMsg; 13 | use chats::{ChatsModel, ChatsMsg}; 14 | use contact::ContactModel; 15 | 16 | #[derive(Debug)] 17 | pub(crate) struct SidebarModel { 18 | chats: Controller, 19 | contact: Controller, 20 | } 21 | 22 | #[derive(Debug)] 23 | pub enum SidebarMsg { 24 | SelectChatroom(i64, bool), 25 | UpdateChatItem(i64, bool, String), 26 | InsertChatItem(i64, bool, String), 27 | PushToast(String), 28 | } 29 | 30 | #[relm4::component(pub)] 31 | impl SimpleComponent for SidebarModel { 32 | type Input = SidebarMsg; 33 | type Output = MainMsg; 34 | type Widgets = SiderbarWidgets; 35 | type InitParams = (); 36 | 37 | view! { 38 | #[root] 39 | sidebar = &Box { 40 | set_vexpand: true, 41 | set_width_request: 320, 42 | set_orientation: Orientation::Vertical, 43 | HeaderBar { 44 | set_show_start_title_buttons: false, 45 | set_show_end_title_buttons: false, 46 | set_title_widget = Some(&ViewSwitcherTitle) { 47 | set_title: "Sidebar", 48 | set_stack: Some(&stack) 49 | } 50 | }, 51 | #[name = "stack"] 52 | ViewStack { 53 | set_vexpand: true, 54 | } 55 | } 56 | } 57 | 58 | fn init( 59 | _init_params: (), 60 | root: &Self::Root, 61 | sender: &ComponentSender, 62 | ) -> ComponentParts { 63 | let model = SidebarModel { 64 | chats: ChatsModel::builder() 65 | .launch(()) 66 | .forward(&sender.input, |message| message), 67 | contact: ContactModel::builder() 68 | .launch(()) 69 | .forward(&sender.input, |message| message), 70 | }; 71 | let widgets = view_output!(); 72 | 73 | let stack: &ViewStack = &widgets.stack; 74 | 75 | let chats = stack.add_titled(model.chats.widget(), None, "Chats"); 76 | let contact = stack.add_titled(model.contact.widget(), None, "Contact"); 77 | 78 | chats.set_icon_name(Some("chat-symbolic")); 79 | contact.set_icon_name(Some("address-book-symbolic")); 80 | 81 | ComponentParts { model, widgets } 82 | } 83 | 84 | fn update(&mut self, msg: SidebarMsg, sender: &ComponentSender) { 85 | use SidebarMsg::*; 86 | match msg { 87 | SelectChatroom(account, is_group) => { 88 | sender.output(MainMsg::SelectChatroom(account, is_group)); 89 | } 90 | UpdateChatItem(account, is_group, last_message) => { 91 | self.chats 92 | .sender() 93 | .send(ChatsMsg::UpdateChatItem(account, is_group, last_message)); 94 | } 95 | InsertChatItem(account, is_group, last_message) => { 96 | self.chats 97 | .sender() 98 | .send(ChatsMsg::InsertChatItem(account, is_group, last_message)); 99 | } 100 | PushToast(message) => sender.output(MainMsg::PushToast(message)), 101 | } 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/app/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod login; 2 | pub mod main; 3 | 4 | use relm4::{ 5 | adw, component::Controller, gtk, Component, ComponentController, ComponentParts, 6 | ComponentSender, SimpleComponent, 7 | }; 8 | 9 | use adw::{prelude::*, ApplicationWindow}; 10 | use gtk::{Box, Stack, StackTransitionType}; 11 | 12 | use crate::{ 13 | actions::create_gactions, 14 | global::{SharedWindow, WINDOW}, 15 | }; 16 | use login::LoginPageModel; 17 | use main::MainPageModel; 18 | 19 | pub struct AppModel { 20 | page: Page, 21 | login: Controller, 22 | main: Controller, 23 | } 24 | 25 | enum Page { 26 | Login, 27 | Main, 28 | } 29 | 30 | #[derive(Debug)] 31 | pub enum AppMessage { 32 | LoginSuccessful, 33 | } 34 | 35 | #[relm4::component(pub)] 36 | impl SimpleComponent for AppModel { 37 | type Widgets = AppWidgets; 38 | type InitParams = (); 39 | type Input = AppMessage; 40 | type Output = (); 41 | 42 | view! { 43 | window = ApplicationWindow { 44 | // add_css_class: "devel", 45 | set_default_size: (1024, 600), 46 | set_content: stack = Some(&Stack) { 47 | set_transition_type: StackTransitionType::SlideLeft, 48 | add_child: login_page = &Box { 49 | append: model.login.widget(), 50 | }, 51 | add_child: main_page = &Box { 52 | append: model.main.widget(), 53 | }, 54 | } 55 | } 56 | } 57 | 58 | fn update(&mut self, msg: Self::Input, _sender: &ComponentSender) { 59 | match msg { 60 | AppMessage::LoginSuccessful => self.page = Page::Main, 61 | } 62 | } 63 | 64 | fn pre_view() { 65 | match model.page { 66 | Page::Login => stack.set_visible_child(login_page), 67 | Page::Main => stack.set_visible_child(main_page), 68 | } 69 | } 70 | 71 | fn init( 72 | _params: Self::InitParams, 73 | root: &Self::Root, 74 | sender: &ComponentSender, 75 | ) -> ComponentParts { 76 | let model = AppModel { 77 | page: Page::Login, 78 | login: LoginPageModel::builder() 79 | .launch(()) 80 | .forward(&sender.input, |message| message), 81 | main: MainPageModel::builder().launch(()).detach(), 82 | }; 83 | let widgets = view_output!(); 84 | 85 | let actions = create_gactions(root.clone()); 86 | root.insert_action_group("menu", Some(&actions)); 87 | 88 | let window_cloned = widgets.window.clone(); 89 | let shared_window = SharedWindow::new(window_cloned); 90 | WINDOW.set(shared_window).unwrap(); 91 | 92 | ComponentParts { model, widgets } 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/config.rs.in: -------------------------------------------------------------------------------- 1 | pub const VERSION: &str = @VERSION@; 2 | pub const APPLICATION_ID: &str = @APPLICATION_ID@; 3 | pub const DB_VERSION: usize = 1; 4 | -------------------------------------------------------------------------------- /src/db/fs.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use crate::utils::avatar::loader::{AvatarLoader, Group, User}; 4 | 5 | pub async fn download_user_avatar_file(user_id: i64) { 6 | User::download_avatar(user_id).await.unwrap(); 7 | } 8 | 9 | pub fn get_user_avatar_path(user_id: i64) -> PathBuf { 10 | User::get_avatar(user_id).unwrap() 11 | } 12 | 13 | pub async fn download_group_avatar_file(group_id: i64) { 14 | Group::download_avatar(group_id).await.unwrap(); 15 | } 16 | 17 | pub fn get_group_avatar_path(group_id: i64) -> PathBuf { 18 | Group::get_avatar(group_id).unwrap() 19 | } 20 | -------------------------------------------------------------------------------- /src/db/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod fs; 2 | pub mod sql; 3 | -------------------------------------------------------------------------------- /src/db/sql.rs: -------------------------------------------------------------------------------- 1 | use std::error::Error; 2 | 3 | use crate::config::DB_VERSION; 4 | use crate::handler::CLIENT; 5 | use resource_loader::{SqlDataBase, SyncCreatePath, SyncLoadResource}; 6 | use ricq::structs::{FriendGroupInfo, FriendInfo, GroupInfo}; 7 | use rusqlite::{params, Connection}; 8 | 9 | pub struct SqlDb; 10 | 11 | impl SyncLoadResource for SqlDb { 12 | type Args = (); 13 | 14 | type Error = rusqlite::Error; 15 | 16 | fn load_resource(_: Self::Args) -> Result { 17 | let db_file = SqlDataBase::create_and_get_path().expect("Failure Load DB information"); 18 | 19 | Connection::open(db_file) 20 | } 21 | } 22 | 23 | pub fn get_db() -> Connection { 24 | SqlDb::load_resource(()).expect("Load Sqlite Db Failure") 25 | } 26 | 27 | #[derive(Debug)] 28 | pub struct Config { 29 | pub key: String, 30 | pub value: String, 31 | } 32 | 33 | #[derive(Debug, Clone)] 34 | pub struct Friend { 35 | pub id: i64, 36 | pub name: String, 37 | // TODO: Make this Option 38 | pub remark: String, 39 | pub group_id: u8, 40 | } 41 | 42 | pub struct FriendsGroup { 43 | pub id: u8, 44 | pub name: String, 45 | pub online_friends: i32, 46 | } 47 | 48 | #[derive(Debug)] 49 | pub struct Group { 50 | pub id: i64, 51 | pub name: String, 52 | } 53 | 54 | pub fn init_sqlite() { 55 | let conn = SqlDb::load_resource(()).expect("Load Sqlite Db Failure"); 56 | 57 | conn.execute( 58 | "Create table if not exists configs ( 59 | key TEXT PRIMARY KEY, 60 | value TEXT NOT NULL 61 | )", 62 | [], 63 | ) 64 | .unwrap(); 65 | 66 | check_db_version(); 67 | 68 | conn.execute( 69 | "Create table if not exists friends ( 70 | id INT PRIMARY KEY, 71 | name TEXT NOT NULL, 72 | remark TEXT NOT NULL, 73 | group_id INT NOT NULL 74 | )", 75 | [], 76 | ) 77 | .unwrap(); 78 | 79 | conn.execute( 80 | "Create table if not exists friends_groups ( 81 | id INT PRIMARY KEY, 82 | name TEXT NOT NULL, 83 | online_friends INT NOT NULL 84 | )", 85 | [], 86 | ) 87 | .unwrap(); 88 | 89 | conn.execute( 90 | "Create table if not exists groups ( 91 | id INT PRIMARY KEY, 92 | name TEXT NOT NULL 93 | )", 94 | [], 95 | ) 96 | .unwrap(); 97 | } 98 | 99 | pub async fn refresh_friends_list() -> Result<(), Box> { 100 | let conn = get_db(); 101 | // Request for friend list 102 | let client = CLIENT.get().unwrap(); 103 | let res = client.get_friend_list().await?; 104 | // Store the friend list in the memory 105 | let friends = res.friends; 106 | let friend_groups = res.friend_groups; 107 | // Handle the `friends_groups` 108 | let mut friends_groups = friend_groups 109 | .iter() 110 | .map(|(_, v)| v.clone()) 111 | .collect::>(); 112 | friends_groups.sort_by(|a, b| a.seq_id.cmp(&b.seq_id)); 113 | let friends_groups = friends_groups 114 | .into_iter() 115 | .map(|friends_group| FriendsGroup { 116 | id: friends_group.group_id, 117 | name: friends_group.group_name, 118 | online_friends: friends_group.online_friend_count, 119 | }); 120 | conn.execute("DELETE FROM friends_groups", [])?; 121 | let mut stmt = conn.prepare("INSERT INTO friends_groups values (?1, ?2, ?3)")?; 122 | for friends_group in friends_groups { 123 | stmt.execute(params![ 124 | friends_group.id, 125 | friends_group.name, 126 | friends_group.online_friends 127 | ])?; 128 | } 129 | // Handle the friends 130 | let friends = friends.into_iter().map( 131 | |FriendInfo { 132 | uin, 133 | nick, 134 | remark, 135 | group_id, 136 | .. 137 | }| Friend { 138 | id: uin, 139 | name: nick, 140 | remark, 141 | group_id, 142 | }, 143 | ); 144 | conn.execute("DELETE FROM friends", [])?; 145 | let mut stmt = conn.prepare("INSERT INTO friends values (?1, ?2, ?3, ?4)")?; 146 | for friend in friends { 147 | stmt.execute(params![ 148 | friend.id, 149 | friend.name, 150 | friend.remark, 151 | friend.group_id 152 | ])?; 153 | } 154 | 155 | Ok(()) 156 | } 157 | 158 | pub async fn refresh_groups_list() -> Result<(), Box> { 159 | let conn = get_db(); 160 | let client = CLIENT.get().unwrap(); 161 | let res = client.get_group_list().await?; 162 | 163 | let groups = res 164 | .into_iter() 165 | .map(|GroupInfo { code, name, .. }| Group { id: code, name }); 166 | 167 | conn.execute("DELETE FROM groups", [])?; 168 | let mut stmt = conn.prepare("INSERT INTO groups values (?1, ?2)")?; 169 | for group in groups { 170 | stmt.execute(params![group.id, group.name])?; 171 | } 172 | 173 | Ok(()) 174 | } 175 | 176 | pub fn get_friend_remark(friend_id: i64) -> String { 177 | get_db() 178 | .query_row( 179 | "Select remark from friends where id=?1", 180 | [friend_id], 181 | |row| row.get(0), 182 | ) 183 | .unwrap_or_else(|_| { 184 | println!("Failed to get friend remark: {}", friend_id); 185 | println!(concat!( 186 | "Help: Try to refresh the friends list in sidebar. ", 187 | "If the problem still exists, please report it on Github.", 188 | )); 189 | friend_id.to_string() 190 | }) 191 | } 192 | 193 | pub fn get_group_name(group_id: i64) -> String { 194 | get_db() 195 | .query_row("Select name from groups where id=?1", [group_id], |row| { 196 | row.get(0) 197 | }) 198 | .unwrap_or_else(|_| { 199 | println!("Failed to get group name: {}", group_id); 200 | println!(concat!( 201 | "Help: Try to refresh the groups list in sidebar. ", 202 | "If the problem still exists, please report it on Github.", 203 | )); 204 | group_id.to_string() 205 | }) 206 | } 207 | 208 | pub fn check_db_version() { 209 | let conn = get_db(); 210 | let res = conn.query_row::( 211 | "Select value from configs where key='db_version'", 212 | [], 213 | |row| row.get(0), 214 | ); 215 | match res { 216 | Ok(version) => { 217 | let version: usize = version.parse().unwrap(); 218 | if version != DB_VERSION { 219 | panic!("unrecognized database version") 220 | } 221 | } 222 | Err(err) => { 223 | if err.to_string() == "Query returned no rows" { 224 | conn.execute( 225 | "Insert into configs values ('db_version', ?1)", 226 | [DB_VERSION.to_string()], 227 | ) 228 | .unwrap(); 229 | } else { 230 | panic!("{}", err); 231 | } 232 | } 233 | } 234 | } 235 | 236 | pub fn load_sql_config( 237 | key: &(impl AsRef + ?Sized), 238 | ) -> Result, rusqlite::Error> { 239 | let conn = get_db(); 240 | let mut stmt = conn.prepare("SELECT value FROM configs where key= ? ")?; 241 | let mut query = stmt.query(params![key.as_ref()])?; 242 | query.next()?.map(|row| row.get::<_, String>(0)).transpose() 243 | } 244 | 245 | pub fn save_sql_config( 246 | key: &(impl AsRef + ?Sized), 247 | value: impl AsRef, 248 | ) -> Result<(), rusqlite::Error> { 249 | let db = get_db(); 250 | 251 | db.execute( 252 | "REPLACE INTO configs (key, value) VALUES (?1, ?2)", 253 | params![key.as_ref(), value.as_ref()], 254 | ) 255 | .map(|_| ()) 256 | } 257 | 258 | #[cfg(test)] 259 | mod test { 260 | 261 | use crate::db::sql::load_sql_config; 262 | 263 | #[test] 264 | fn test_account_load() { 265 | let acc = load_sql_config("account"); 266 | 267 | println!("{acc:?}") 268 | } 269 | } 270 | -------------------------------------------------------------------------------- /src/global/mod.rs: -------------------------------------------------------------------------------- 1 | use relm4::{adw, gtk}; 2 | 3 | use adw::{Application, ApplicationWindow}; 4 | use gtk::gdk_pixbuf::Pixbuf; 5 | use gtk::gio::Notification; 6 | use gtk::prelude::ApplicationExt; 7 | 8 | use once_cell::sync::OnceCell; 9 | use tokio::task; 10 | 11 | use crate::db::{ 12 | fs::{ 13 | download_group_avatar_file, download_user_avatar_file, get_group_avatar_path, 14 | get_user_avatar_path, 15 | }, 16 | sql::{get_friend_remark, get_group_name}, 17 | }; 18 | 19 | #[derive(Debug)] 20 | pub struct SharedApplication { 21 | pub app: Application, 22 | } 23 | 24 | unsafe impl Sync for SharedApplication {} 25 | unsafe impl Send for SharedApplication {} 26 | 27 | impl SharedApplication { 28 | pub fn new(app: Application) -> Self { 29 | SharedApplication { app } 30 | } 31 | 32 | pub fn notify_friend_message(&self, friend_id: i64, content: &String) { 33 | let title = get_friend_remark(friend_id); 34 | let path = get_user_avatar_path(friend_id); 35 | 36 | let notification = Notification::new(&title); 37 | notification.set_body(Some(content)); 38 | 39 | if path.exists() { 40 | if let Ok(icon) = Pixbuf::from_file(path) { 41 | notification.set_icon(&icon); 42 | } 43 | } else { 44 | task::spawn(download_user_avatar_file(friend_id)); 45 | } 46 | 47 | self.app.send_notification(None, ¬ification); 48 | } 49 | 50 | pub fn notify_group_message(&self, group_id: i64, content: &String) { 51 | let title = get_group_name(group_id); 52 | let path = get_group_avatar_path(group_id); 53 | 54 | let notification = Notification::new(&title); 55 | notification.set_body(Some(content)); 56 | 57 | if path.exists() { 58 | if let Ok(icon) = Pixbuf::from_file(path) { 59 | notification.set_icon(&icon); 60 | } 61 | } else { 62 | task::spawn(download_group_avatar_file(group_id)); 63 | } 64 | 65 | self.app.send_notification(None, ¬ification); 66 | } 67 | } 68 | 69 | pub static APP: OnceCell = OnceCell::new(); 70 | 71 | #[derive(Debug)] 72 | pub struct SharedWindow { 73 | pub window: ApplicationWindow, 74 | } 75 | 76 | unsafe impl Sync for SharedWindow {} 77 | unsafe impl Send for SharedWindow {} 78 | 79 | impl SharedWindow { 80 | pub fn new(window: ApplicationWindow) -> Self { 81 | SharedWindow { window } 82 | } 83 | } 84 | 85 | pub static WINDOW: OnceCell = OnceCell::new(); 86 | -------------------------------------------------------------------------------- /src/handler.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use async_trait::async_trait; 4 | use once_cell::sync::OnceCell; 5 | use ricq::client::event::*; 6 | use ricq::handler::{Handler, QEvent::*}; 7 | use ricq::Client; 8 | 9 | use crate::app::main::{MainMsg, MAIN_SENDER}; 10 | use crate::db::sql::get_friend_remark; 11 | use crate::utils::message::{get_contents_from, get_text_from, Message}; 12 | use crate::APP; 13 | 14 | pub struct AppHandler; 15 | 16 | pub static CLIENT: OnceCell> = OnceCell::new(); 17 | pub static ACCOUNT: OnceCell = OnceCell::new(); 18 | 19 | #[async_trait] 20 | impl Handler for AppHandler { 21 | async fn handle(&self, event: ricq::handler::QEvent) { 22 | match event { 23 | Login(_) => {} 24 | GroupMessage(GroupMessageEvent { inner, .. }) => { 25 | let main_sender = MAIN_SENDER.get().expect("failed to get main sender"); 26 | let content = get_contents_from(&inner.elements); 27 | main_sender.input(MainMsg::GroupMessage { 28 | group_id: inner.group_code, 29 | message: Message { 30 | sender_id: inner.from_uin, 31 | sender_name: inner.group_card, 32 | contents: content.clone(), 33 | }, 34 | }); 35 | 36 | // Send notification 37 | if &inner.from_uin != ACCOUNT.get().unwrap() { 38 | let app = APP.get().unwrap(); 39 | app.notify_group_message(inner.group_code, &get_text_from(&content)); 40 | } 41 | } 42 | #[allow(unused_variables)] 43 | GroupAudioMessage(GroupAudioMessageEvent { client, inner }) => { 44 | println!("GroupAudioMessage"); 45 | } 46 | FriendMessage(FriendMessageEvent { inner, .. }) => { 47 | let main_sender = MAIN_SENDER.get().expect("failed to get main sender"); 48 | let self_account = ACCOUNT.get().unwrap(); 49 | let friend_id = if inner.from_uin == *self_account { 50 | inner.target 51 | } else { 52 | inner.from_uin 53 | }; 54 | let contents = get_contents_from(&inner.elements); 55 | main_sender.input(MainMsg::FriendMessage { 56 | friend_id, 57 | message: Message { 58 | sender_id: inner.from_uin, 59 | sender_name: get_friend_remark(inner.from_uin), 60 | contents: contents.clone(), 61 | }, 62 | }); 63 | 64 | // Send notification 65 | if inner.from_uin != *self_account { 66 | let app = APP.get().unwrap(); 67 | app.notify_friend_message(friend_id, &get_text_from(&contents)); 68 | } 69 | } 70 | #[allow(unused_variables)] 71 | FriendAudioMessage(FriendAudioMessageEvent { client, inner }) => { 72 | println!("FriendAudioMessage"); 73 | } 74 | #[allow(unused_variables)] 75 | GroupTempMessage(GroupTempMessageEvent { client, inner }) => { 76 | println!("GroupTempMessage"); 77 | } 78 | #[allow(unused_variables)] 79 | SelfInvited(SelfInvitedEvent { client, inner }) => { 80 | println!("SelfInvited"); 81 | } 82 | #[allow(unused_variables)] 83 | NewMember(NewMemberEvent { client, inner }) => { 84 | println!("NewMember"); 85 | } 86 | #[allow(unused_variables)] 87 | GroupMute(GroupMuteEvent { client, inner }) => { 88 | println!("GroupMute"); 89 | } 90 | #[allow(unused_variables)] 91 | FriendMessageRecall(FriendMessageRecallEvent { client, inner }) => { 92 | println!("FriendMessageRecall"); 93 | } 94 | #[allow(unused_variables)] 95 | GroupMessageRecall(GroupMessageRecallEvent { client, inner }) => { 96 | println!("GroupMessageRecall"); 97 | } 98 | #[allow(unused_variables)] 99 | NewFriend(NewFriendEvent { client, inner }) => { 100 | println!("NewFriend"); 101 | } 102 | #[allow(unused_variables)] 103 | GroupLeave(GroupLeaveEvent { client, inner }) => { 104 | println!("GroupLeave"); 105 | } 106 | #[allow(unused_variables)] 107 | GroupDisband(GroupDisbandEvent { client, inner }) => { 108 | println!("GroupDisband"); 109 | } 110 | #[allow(unused_variables)] 111 | FriendPoke(FriendPokeEvent { client, inner }) => { 112 | println!("FriendPoke"); 113 | } 114 | #[allow(unused_variables)] 115 | GroupNameUpdate(GroupNameUpdateEvent { client, inner }) => { 116 | println!("GroupNameUpdate"); 117 | } 118 | #[allow(unused_variables)] 119 | DeleteFriend(DeleteFriendEvent { client, inner }) => { 120 | println!("DeleteFriend"); 121 | } 122 | #[allow(unused_variables)] 123 | MemberPermissionChange(MemberPermissionChangeEvent { client, inner }) => { 124 | println!("MemberPermissionChange"); 125 | } 126 | #[allow(unused_variables)] 127 | KickedOffline(KickedOfflineEvent { client, inner }) => { 128 | println!("KickedOffline"); 129 | } 130 | #[allow(unused_variables)] 131 | MSFOffline(MSFOfflineEvent { client, inner }) => { 132 | println!("MSFOffline"); 133 | } 134 | #[allow(unused_variables)] 135 | GroupRequest(_) => { 136 | println!("GroupRequest"); 137 | } 138 | #[allow(unused_variables)] 139 | NewFriendRequest(_) => { 140 | println!("NewFriendRequest"); 141 | } 142 | }; 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | #![feature(async_closure)] 2 | #![feature(strict_provenance)] 3 | 4 | mod actions; 5 | mod app; 6 | mod config; 7 | mod db; 8 | mod global; 9 | mod handler; 10 | mod utils; 11 | 12 | use gio::{resources_register, Cancellable, Resource}; 13 | use gtk::{gio, glib::Bytes, prelude::ApplicationExt}; 14 | use relm4::{gtk, RelmApp}; 15 | 16 | use app::AppModel; 17 | use db::sql::init_sqlite; 18 | use global::{SharedApplication, APP}; 19 | use resource_loader::ResourceConfig; 20 | 21 | #[tokio::main] 22 | async fn main() { 23 | ResourceConfig::load_or_create_default().expect("Failure on loading configuration"); 24 | init_resources(); 25 | init_sqlite(); 26 | 27 | let app: RelmApp = RelmApp::new(config::APPLICATION_ID); 28 | app.app.register(Option::<&Cancellable>::None).unwrap(); 29 | relm4::set_global_css(include_bytes!("styles/style.css")); 30 | 31 | let shared_app = SharedApplication::new(app.app.clone()); 32 | APP.set(shared_app).unwrap(); 33 | 34 | app.run(()); 35 | } 36 | 37 | fn init_resources() { 38 | let res_bytes = Bytes::from(include_bytes!("../builddir/assets/resources.gresource")); 39 | let res = Resource::from_data(&res_bytes).unwrap(); 40 | resources_register(&res); 41 | } 42 | -------------------------------------------------------------------------------- /src/meson.build: -------------------------------------------------------------------------------- 1 | conf = configuration_data() 2 | conf.set_quoted('VERSION', meson.project_version()) 3 | conf.set_quoted('APPLICATION_ID', application_id) 4 | 5 | config_output_file = configure_file( 6 | input: 'config.rs.in', 7 | output: 'config.rs', 8 | configuration: conf 9 | ) 10 | 11 | # Copy the config.rs output to the source directory. 12 | if build_machine.system() == 'windows' 13 | run_command( 14 | 'Xcopy', 15 | config_output_file, 16 | meson.current_source_dir(), 17 | # overwrite exist file 18 | '/y', 19 | check: true 20 | ) 21 | else 22 | run_command( 23 | 'cp', 24 | config_output_file, 25 | meson.current_source_dir(), 26 | check: true 27 | ) 28 | endif -------------------------------------------------------------------------------- /src/styles/style.css: -------------------------------------------------------------------------------- 1 | .chatroom-box { 2 | padding: 8px; 3 | } 4 | -------------------------------------------------------------------------------- /src/utils/avatar/error.rs: -------------------------------------------------------------------------------- 1 | use std::io; 2 | 3 | #[derive(Debug)] 4 | pub enum AvatarError { 5 | Io(io::Error), 6 | Request(reqwest::Error), 7 | Glib(relm4::gtk::glib::Error), 8 | } 9 | 10 | impl std::error::Error for AvatarError {} 11 | 12 | impl std::fmt::Display for AvatarError { 13 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 14 | match self { 15 | AvatarError::Io(err) => write!(f, "Avatar Io Error : {}", err), 16 | AvatarError::Request(err) => write!(f, "Avatar Request Error : {}", err), 17 | AvatarError::Glib(err) => write!(f, "Avatar GLib Error : {}", err), 18 | } 19 | } 20 | } 21 | 22 | impl From for AvatarError { 23 | fn from(err: io::Error) -> Self { 24 | AvatarError::Io(err) 25 | } 26 | } 27 | 28 | impl From for AvatarError { 29 | fn from(err: reqwest::Error) -> Self { 30 | AvatarError::Request(err) 31 | } 32 | } 33 | 34 | impl From for AvatarError { 35 | fn from(err: relm4::gtk::glib::Error) -> Self { 36 | AvatarError::Glib(err) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/utils/avatar/loader/group.rs: -------------------------------------------------------------------------------- 1 | use std::{borrow::Cow, io, path::Path}; 2 | 3 | use resource_loader::{AvatarGroup, SyncCreatePath}; 4 | 5 | use super::AvatarLoader; 6 | use crate::utils::DirAction; 7 | 8 | pub struct Group; 9 | 10 | impl AvatarLoader for Group { 11 | fn get_avatar_location_dir(action: DirAction) -> io::Result<&'static Path> { 12 | AvatarGroup::do_action_and_get_path(action) 13 | } 14 | 15 | fn avatar_download_url(id: i64) -> Cow<'static, String> { 16 | Cow::Owned(format!("https://p.qlogo.cn/gh/{}/{}/0", id, id)) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/utils/avatar/loader/mod.rs: -------------------------------------------------------------------------------- 1 | mod group; 2 | mod user; 3 | 4 | use std::{ 5 | borrow::Cow, 6 | future::Future, 7 | io, 8 | path::{Path, PathBuf}, 9 | pin::Pin, 10 | }; 11 | 12 | use super::error::AvatarError; 13 | use crate::utils::DirAction; 14 | pub use group::Group; 15 | use relm4::gtk::gdk_pixbuf::Pixbuf; 16 | pub use user::User; 17 | 18 | pub trait AvatarLoader { 19 | fn get_avatar_location_dir(action: DirAction) -> io::Result<&'static Path>; 20 | fn avatar_download_url(id: i64) -> Cow<'static, String>; 21 | 22 | fn download_avatar( 23 | id: i64, 24 | ) -> Pin> + Send + Sync>> { 25 | use tokio::fs::write; 26 | 27 | let filename = ::get_avatar_filename(id, DirAction::CreateAll); 28 | let url = ::avatar_download_url(id); 29 | 30 | Box::pin(async move { 31 | println!("Downloading {}", url); 32 | let body = reqwest::get(&*url).await?.bytes().await?; 33 | 34 | write(filename?, &body).await?; 35 | 36 | Ok(()) 37 | }) 38 | } 39 | 40 | fn get_avatar_filename(id: i64, action: DirAction) -> io::Result { 41 | ::get_avatar_location_dir(action) 42 | .map(|p| p.join(format!("{}.png", id))) 43 | } 44 | 45 | fn get_avatar(id: i64) -> io::Result { 46 | ::get_avatar_filename(id, DirAction::None) 47 | } 48 | 49 | fn get_avatar_as_pixbuf(id: i64, width: i32, height: i32) -> Result { 50 | let path = ::get_avatar(id)?; 51 | if !path.exists() { 52 | Err(io::Error::new( 53 | io::ErrorKind::NotFound, 54 | "Target Avatar Not Found", 55 | ))?; 56 | } 57 | let pix_buf = Pixbuf::from_file_at_size(path, width, height)?; 58 | 59 | Ok(pix_buf) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/utils/avatar/loader/user.rs: -------------------------------------------------------------------------------- 1 | use std::{borrow::Cow, io, path::Path}; 2 | 3 | use resource_loader::{AvatarUser, SyncCreatePath}; 4 | 5 | use super::AvatarLoader; 6 | use crate::utils::DirAction; 7 | 8 | pub struct User; 9 | 10 | impl AvatarLoader for User { 11 | fn get_avatar_location_dir(action: DirAction) -> io::Result<&'static Path> { 12 | AvatarUser::do_action_and_get_path(action) 13 | } 14 | 15 | fn avatar_download_url(id: i64) -> Cow<'static, String> { 16 | Cow::Owned(format!( 17 | "http://q2.qlogo.cn/headimg_dl?dst_uin={}&spec=160", 18 | &id 19 | )) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/utils/avatar/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod error; 2 | pub mod loader; 3 | -------------------------------------------------------------------------------- /src/utils/message/content.rs: -------------------------------------------------------------------------------- 1 | #[derive(Debug, Clone)] 2 | #[allow(dead_code)] 3 | pub(crate) enum Content { 4 | Text(String), 5 | Image { url: String, filename: String }, 6 | } 7 | 8 | impl Content { 9 | pub(crate) fn text(&self) -> String { 10 | match self { 11 | Content::Text(text) => text.clone(), 12 | Content::Image { .. } => "[图片]".to_string(), 13 | } 14 | } 15 | } 16 | 17 | pub(crate) fn get_text_from(contents: &[Content]) -> String { 18 | contents 19 | .iter() 20 | .map(|content| content.text()) 21 | .collect::>() 22 | .join("") 23 | } 24 | -------------------------------------------------------------------------------- /src/utils/message/mod.rs: -------------------------------------------------------------------------------- 1 | mod content; 2 | mod utils; 3 | 4 | pub(crate) use self::content::get_text_from; 5 | pub(crate) use self::content::Content; 6 | pub(crate) use self::utils::get_contents_from; 7 | #[derive(Clone, Debug)] 8 | pub(crate) struct Message { 9 | pub sender_id: i64, 10 | pub sender_name: String, 11 | pub contents: Vec, 12 | } 13 | 14 | impl Message { 15 | pub(crate) fn text(&self) -> String { 16 | get_text_from(&self.contents) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/utils/message/utils.rs: -------------------------------------------------------------------------------- 1 | use super::Content; 2 | use ricq::msg::elem::{FingerGuessing, FlashImage, RQElem}; 3 | use ricq::msg::MessageChain; 4 | 5 | pub(crate) fn get_contents_from(message_chain: &MessageChain) -> Vec { 6 | let mut contents = Vec::::new(); 7 | for elem in message_chain.clone() { 8 | match elem { 9 | RQElem::At(at) => { 10 | contents.push(Content::Text(format!("[{}({})]", at.display, at.target))); 11 | } 12 | RQElem::Text(ref text) => { 13 | contents.push(Content::Text(text.content.clone())); 14 | } 15 | RQElem::Face(face) => { 16 | contents.push(Content::Text(format!("[{}]", face.name))); 17 | } 18 | RQElem::MarketFace(face) => { 19 | contents.push(Content::Text(format!("[{}]", face.name))); 20 | } 21 | RQElem::Dice(dice) => { 22 | contents.push(Content::Text(format!("[🎲({})]", dice.value))); 23 | } 24 | RQElem::FingerGuessing(finger_guessing) => { 25 | contents.push(Content::Text( 26 | match finger_guessing { 27 | FingerGuessing::Rock => "[✊]", 28 | FingerGuessing::Scissors => "[✌]", 29 | FingerGuessing::Paper => "[✋]", 30 | } 31 | .to_string(), 32 | )); 33 | } 34 | RQElem::LightApp(light_app) => { 35 | contents.push(Content::Text("[LIGHT_APP MESSAGE]".to_string())); 36 | println!("LightApp: {:#?}", light_app); 37 | } 38 | RQElem::RichMsg(rich_msg) => { 39 | contents.push(Content::Text("[RICH MESSAGE]".to_string())); 40 | println!("RichMsg: {:#?}", rich_msg); 41 | } 42 | RQElem::FriendImage(image) => { 43 | let content = Content::Image { 44 | url: image.url(), 45 | filename: image.file_path, 46 | }; 47 | contents.push(content); 48 | } 49 | RQElem::GroupImage(image) => { 50 | let content = Content::Image { 51 | url: image.url(), 52 | filename: image.file_path, 53 | }; 54 | contents.push(content); 55 | } 56 | RQElem::FlashImage(image) => { 57 | let content = Content::Image { 58 | url: image.url(), 59 | filename: get_flash_image_path(image), 60 | }; 61 | contents.push(content); 62 | } 63 | RQElem::VideoFile(_) => { 64 | contents.push(Content::Text("[视频文件]".to_string())); 65 | } 66 | RQElem::Other(_) => {} 67 | } 68 | } 69 | contents 70 | } 71 | 72 | fn get_flash_image_path(image: FlashImage) -> String { 73 | match image { 74 | FlashImage::FriendImage(image) => image.file_path, 75 | FlashImage::GroupImage(image) => image.file_path, 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/utils/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod avatar; 2 | pub mod message; 3 | 4 | pub use resource_loader::DirAction; 5 | --------------------------------------------------------------------------------