最近要使用小程序码生成工具,捡起了以前开发的使用 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/