最近要使用小程序码生成工具,捡起了以前开发的使用 Rust + native-windows-gui 打造极致轻量的小程序工具。
我发现这个工具最大的痛点就是每次打开都需要重复输入appid和appsecret。我决定修复它,我没有重新开始该项目,是因为我准备使用最近很流行的egui库。使用这个库的另一个原因是我为开发ESP网关客户端做技术储备。
在开发的中间,我还想使用iced,但是开发了一段时间放弃了,不是iced不好,是因为我没有精力。它适合长期的大型的项目。我现在想快速开发完成,所以egui的界面我忍受了。
开发完成的初版界面如下:
它极致轻量,只有一个小巧的运行文件,无需安装臃肿的运行库。响应迅速毫秒级启动,操作如丝般顺滑,没有多余的动效和加载等待。
下载工具后,放入你要生成小程序码的文件夹中,打开工具,填入小程序参数(AppID、路径等),一键获取高清太阳码,小程序码图片就会输出到本文件夹中。 最为暖心的是,应用重新启动后,不用重新输入appid和appsecret,并且会自动获取token。
下面看看核心代码片段:
// --- 主应用状态 ---
pub struct BeanApp {
store: AppConfig, // 保存信息
appid_raw: String, // appid
secret_raw: String, // appsecret
access_token: Option<String>, // 存储 Token
expires_in: u64, // 存储过期时间戳 (秒)
output_dir: String, // 小程序码输出目录
is_pro: bool, // 是否开启了高级功能
active_tab: usize, // 0: 单张模式, 1: 批量模式
page_path: String, // 页面路径
scene_value: String, // 场景值
qr_size: u32, // 导出尺寸
show_crop_line: bool, // 是否显示裁剪线
preview_texture: Option<TextureHandle>, // 预览图句柄
status_msg: String, // 状态信息
show_password: bool, // 是否显示密码
tx: Sender<AppMessage>, // 发送端
rx: Receiver<AppMessage>, // 接收端
ctx: egui::Context, // 保存上下文,用于异步刷新
is_loading_token: bool, // 防止重复请求
}
这是最核心的内容,egui就靠这个结构体,让UI和业务逻辑进行交互。本质就是UI交互区修改这个结构体中的一些内容,然后调用业务逻辑去处理内容,然后再返回信息到这个结构体,然后UI读取这些值的内容再显示出来。
这是存储在文件中的APP配置信息,包含appid,appsecret,token以及过期时间等。
#[derive(Serialize, Deserialize, Default)]
pub struct AppConfig {
pub app_id: String, // appid
pub app_secret: String, // appsecret
pub access_token: String, // 存储 Token
pub expires_in: u64, // 存储过期时间戳 (秒)
pub output_dir: String,
pub is_pro: bool,
}
这个是解决打开客户端不用重新输入appid的秘籍。当客户端打开后,会从文件读取并反序列化为AppConfig结构体,判断token的过期时间是否到达,当过期了,重新获取token,没有过期则使用存储的token。这样可以避免频繁调用微信的API接口。注意到里面有个字段output_dir,这个是自定义小程序码的输出路径,还未实现,所以不支持。
当获取到appid和appsecret的值后,就可以调用微信API获取token,这个token是获取小程序码的关键参数。需要注意的是,如果在微信管理后台开启了白名单,请记得添加自己的客户端电脑公网IP地址,否则无法获取token。
这是调用token的核心代码片段:
fn get_mp_token(&self) {
let ctx = self.ctx.clone();
let tx = self.tx.clone();
let appid = self.appid_raw.clone();
let secret = self.secret_raw.clone();
// 1. 在启动异步任务前先进行判断
if appid.is_empty() || secret.is_empty() {
let _ = tx.send(AppMessage::Status(
"❌ 失败:AppID 或 Secret 为空,请先设置".into(),
));
return; // 直接跳出,不执行后续逻辑
}
// 启动一个后台线程去拿 Token
RUNTIME.spawn(async move {
let url = format!(
"https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid={}&secret={}",
appid, secret
);
match reqwest::get(url).await {
Ok(resp) => {
if let Ok(json) = resp.json::<serde_json::Value>().await {
println!("token {}", json);
// 1. 先检查是否有 errcode 且不为 0
if let Some(err_code) = json["errcode"].as_i64() {
if err_code != 0 {
let err_msg = json["errmsg"].as_str().unwrap_or("未知错误");
let _ = tx.send(AppMessage::Status(format!("❌ 微信报错: [{}] {}", err_code, err_msg)));
return; // 直接返回,不再往下走
}
}
if let (Some(token), Some(expired)) = (
json["access_token"].as_str(),
json["expires_in"].as_u64(), // 提取为 u64
) {
// 将 token 转为 String 传给枚举
let _ = tx.send(AppMessage::Token(token.to_string(), expired));
} else {
let _ =
tx.send(AppMessage::Status("❌ 获取失败:凭证或有效期错误".into()));
}
}
}
Err(_) => {
let _ = tx.send(AppMessage::Status("❌ 网络连接失败".into()));
}
}
ctx.request_repaint(); // 拿到结果后通知 UI 刷新
});
}
这里使用tokio开启新线程去获取,这样就不会阻塞主UI线程,导致卡顿。在egui环境下,使用异步,需要初始化Token运行时,我这里使用全局实例:
// 创建全局运行时
static RUNTIME: Lazy<Runtime> =
Lazy::new(|| Runtime::new().expect("Failed to create Tokio runtime"));
当获取token API执行后,会通过tx通道发送消息,并通过ctx重绘通知UI刷新获取结果。
在BeanApp new函数中我们创建了同步通道,并复制给BeanApp实例。
let (tx, rx) = std::sync::mpsc::channel();
这样,业务线程拿着Tx发送端,UI主线程拿着Rx接收端,两个线程之间就可以通信。
当拿到token 之后,我们会保存到BeanApp实例中,这样当用户输入生成小程序码的路径和scene值后,就可以直接调用微信API生成小程序码。这里仅实现了无限制生成小程序的接口。因为这是我需要的,有其它接口需求可以联系我。
/// 调用微信接口生成太阳码并保存到 output_dir
fn generate_mp_code(&self) {
let token_str = self.access_token.clone().unwrap_or_default();
let output_path = std::path::PathBuf::from(&self.output_dir);
let tx = self.tx.clone();
let ctx = self.ctx.clone();
// 只有当 Option 是 Some 且 字符串不为空时,才算“有值”
// if token.as_deref().unwrap_or("").is_empty() {
// 判空
// 后续判断时:只需要判断 is_none() 即可
if self.access_token.is_none() {
let _ = tx.send(AppMessage::Status(
"❌ 错误:请先设置APPID和APPSECRET".into(),
));
return;
}
let scene = self.scene_value.clone();
let page = self.page_path.clone();
let qr_size = self.qr_size;
if self.active_tab == 0 {
if scene.is_empty() || page.is_empty() {
let _ = tx.send(AppMessage::Status("❌ 错误:请先设置页面和场景值".into()));
return;
}
} else {
// 判断是否激活,现在直接返回报错
let _ = tx.send(AppMessage::Status(
"❌ 错误:批次模式未激活,请先激活".into(),
));
return;
}
RUNTIME.spawn(async move {
// 不受限制小程序码
let url = format!(
"https://api.weixin.qq.com/wxa/getwxacodeunlimit?access_token={}",
token_str
);
// 构造微信要求的 JSON 请求体
let body = serde_json::json!({
"scene": scene,
"page": page,
"width": qr_size,
});
let client = reqwest::Client::new();
match client.post(url).json(&body).send().await {
Ok(resp) => {
// 检查返回的是否是图片内容
let content_type = resp
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
if content_type.contains("image") {
if let Ok(bytes) = resp.bytes().await {
// 1. 确保输出目录存在
if !output_path.exists() {
let _ = std::fs::create_dir_all(&output_path);
}
// 2. 生成文件名(以时间戳命名,防止覆盖)
let filename = format!(
"suncode_{}.png",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs()
);
let _ = tx.send(AppMessage::Image(bytes.to_vec()));
let file_full_path = output_path.join(filename);
// 3. 写入二进制文件
if std::fs::write(&file_full_path, bytes).is_ok() {
let _ = tx.send(AppMessage::Status(format!(
"✅ 太阳码已保存至: {:?}",
file_full_path
)));
} else {
let _ = tx.send(AppMessage::Status("❌ 文件写入失败".into()));
}
}
} else {
let _ = tx.send(AppMessage::Status(
"❌ 获取失败:检查页面路径或场景值".into(),
));
}
}
Err(_) => {
let _ = tx.send(AppMessage::Status("❌ 网络请求失败".into()));
}
}
ctx.request_repaint();
});
}
在代码里,我们可以看到和获取token的方式差不多,都需要tokio创建一个线程去执行网络请求以免阻塞主UI线程。我们仅需要按照微信要求的格式传递相应参数就可以了,返回的小程序码是图片二进制数据,我们可以直接存入图片文件中。
我做了一个图片预览功能,这个需要使用到image库。
AppMessage::Image(bytes) => {
// ... 图片处理逻辑 ...
if let Ok(image) = image::load_from_memory(&bytes) {
let size = [image.width() as _, image.height() as _];
let image_buffer = image.to_rgba8();
let pixels = image_buffer.as_flat_samples();
let color_image =
egui::ColorImage::from_rgba_unmultiplied(size, pixels.as_slice());
self.preview_texture = Some(ctx.load_texture(
"suncode-preview",
color_image,
Default::default(),
));
self.status_msg = "✅ 预览已生成".into();
}
}
当接收到图片数据后,需要使用image库读取,然后通过TextureHandle要求的格式传递给它,就能显示在界面上。
主要功能就这些,没有精力继续开发花哨实用的功能了。我要把时间精力放在嵌入式ESP网关上了。有兴趣的可以联系我,我会有动力继续下去。
下载豆子太阳码管家地址:https://91demo.top/tools/