Last updated on

本地小工具不同机器上设置差异化处理方法

问题&初步设想

在写自己的博客文章转化工具的时候,我做了ui上的更改,把配置部分作为一个弹窗,隐藏到设置按钮里面了

image-20260318095947005

原本这个配置是在解析markdown的上方的,所以用户肯定会填入自己的配置(当然其实每次使用填入也很不合理),只是这次ui改动让我注意到了这个问题,因为我在使用的时候没有配置根目录就出现了如下情况——解析失败:

image-20260318100104945

现在的需求就是:

  • 初始化的时候需要在设置处放置醒目提示,让用户做配置的初始化
  • 后续每一次进入,需要能够读取到在本机上做的配置,如果有配置,则不需要再设置出提醒,而是直接使用配置
  • 配置不能写死在代码里,因为用户使用的是打包好的exe文件,而且每个用户的博客根目录都不一样
  • 后续开放更高程度的自定义需要能够复用这个设计思路,使得更多结构的博客能被我的小工具兼容

我先询问了codex:

image-20260318100457866

后续得出的解决方法:

  • 配置必须本地化在核心代码之外的地方
  • exe内部不合适存储toml配置,因为其是打包好的内容,不会更改
  • 应该存储在windows为用户准备的配置存储区域,和微信等程序一样
  • 因此会存放在%AppData%的相关路径之下

解决方案&详细总结

配置实际存储地址

我的后端配置读写逻辑使用的是 Tauri 提供的应用配置目录。

核心 Rust 代码如下:

use std::fs;
use std::path::PathBuf;
use tauri::Manager;
use crate::error::{AppError, AppResult};
use crate::models::{AppConfig, ConfigEnvelope};
pub fn config_file_path(app: &tauri::AppHandle) -> AppResult<PathBuf> {
let dir = app
.path()
.app_config_dir()
.map_err(|e| AppError::Message(format!("无法获取本地配置目录:{e}")))?;
Ok(dir.join("config.toml"))
}

这段代码的含义是:

  1. 先取应用配置目录 app_config_dir()
  2. 然后把配置文件固定为 config.toml

也就是说,我的配置最终不是存在程序目录里,而是存在:

应用配置目录 / config.toml

Windows 上为什么最终会落到 AppData

Tauri 应用有一个应用标识符 identifier,我在配置里是这样写的:

{
"productName": "Blog Format Tool",
"identifier": "com.local.blog-format-tool"
}

在桌面端,Tauriapp_config_dir() 本质上等价于:

系统配置目录 / 应用 identifier

在 Windows 上,这个“系统配置目录”通常就是:

%APPDATA%

所以最终的配置路径会变成:

%APPDATA%\com.local.blog-format-tool\config.toml

在我本机上,它实际展开成:

C:\Users\admin\AppData\Roaming\com.local.blog-format-tool\config.toml

为什么不同机器上的配置天然不会一样

因为 %APPDATA%当前 Windows 用户自己的配置目录,它不是固定死的公共路径。

例如:

  • 用户 A 的 %APPDATA% 可能是 C:\Users\Alice\AppData\Roaming
  • 用户 B 的%APPDATA%可能是 C:\Users\Bob\AppData\Roaming

所以同一个 exe

  • 在我的电脑上运行,会写我的配置
  • 在别人的电脑上运行,会写别人的配置
  • 在同一台电脑的不同 Windows 账户下运行,也会分别写各自的配置

这正好符合我的需求:

每台设备 / 每个用户各自保存一次,后续自动沿用本机配置。

配置如何读取:不存在就返回默认值

我的读取逻辑是:

pub fn load_or_default(app: &tauri::AppHandle) -> AppResult<ConfigEnvelope> {
let path = config_file_path(app)?;
let config = if path.exists() {
let s = fs::read_to_string(&path)
.map_err(|e| AppError::Message(format!("读取配置文件失败({}):{e}", path.display())))?;
toml::from_str::<AppConfig>(&s)
.map_err(|e| AppError::Message(format!("解析配置文件失败({}):{e}", path.display())))?
} else {
AppConfig::default()
};
Ok(ConfigEnvelope {
config_path: path.to_string_lossy().to_string(),
config,
})
}

这意味着:

  • 如果config.toml已经存在,就读取它
  • 如果不存在,就返回默认配置对象

这里的“默认配置对象”不是一个写死的博客目录,而只是一个空配置结构。 例如 repoRoot 默认是空字符串:

const DEFAULT_CONFIG: AppConfig = {
repoRoot: "",
blogContentDir: "src/content/blog",
essaysContentDir: "src/content/essays",
imagesDir: "public/images",
imageSubdirStrategy: "title",
copyMode: "copy",
dateFormat: "%Y-%m-%d",
descriptionAutoLength: 80,
fileNameStrategy: "original",
};

所以第一次启动时,程序不会崩,而是会进入“尚未完成初始化”的状态。

配置如何保存:创建目录并写入 config.toml

保存逻辑如下:

pub fn save(app: &tauri::AppHandle, config: AppConfig) -> AppResult<ConfigEnvelope> {
let path = config_file_path(app)?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|e| AppError::Message(format!("创建配置目录失败({}):{e}", parent.display())))?;
}
let s = toml::to_string_pretty(&config)
.map_err(|e| AppError::Message(format!("序列化配置失败:{e}")))?;
fs::write(&path, s)
.map_err(|e| AppError::Message(format!("写入配置文件失败({}):{e}", path.display())))?;
Ok(ConfigEnvelope {
config_path: path.to_string_lossy().to_string(),
config,
})
}

这个过程做了三件事:

  1. 先得到配置文件路径
  2. 如果目录不存在,就自动创建
  3. 把当前配置写进 config.toml

也就是说,用户第一次点击“保存设置”时,程序就会在本机配置目录里落下一份配置文件,后续每次启动都可以直接复用。

如何判断“是不是首次使用当前设备”

在前端,我没有单独设计一个复杂的“首次启动标记文件”,而是用一个更直接的判断方式:

只要 repoRoot 是空的,就认为当前设备还没有完成初始化。

前端状态是这样定义的:

const state = {
lastAnalyze: null as AnalyzeResult | null,
convertResetTimer: null as number | null,
tags: [] as string[],
needsSetup: false,
};

真正切换状态的核心函数是:

function applySetupState(repoRoot: string) {
const needsSetup = !repoRoot.trim();
state.needsSetup = needsSetup;
getEl<HTMLSpanElement>("#settingsDot").classList.toggle("hidden", !needsSetup);
getEl<HTMLElement>("#setupBanner").classList.toggle("hidden", !needsSetup);
}

这段逻辑表示:

  • 如果 repoRoot为空,说明还没设置过
  • 如果 repoRoot 不为空,说明当前设备已经完成初始化

首次使用时,如何提示用户去设置

我设计了两个提示入口:

  1. 右上角设置按钮上的小圆点
  2. 顶部的一条轻量引导条

对应的 HTML 结构如下:

<button id="openSettings" class="ghost-button square-button" type="button" aria-label="打开设置" title="设置">
<span class="settings-dot hidden" id="settingsDot" aria-hidden="true"></span>
<span class="gear-icon" aria-hidden="true">...</span>
</button>
<section class="setup-banner hidden" id="setupBanner">
<div class="setup-copy">
<div class="setup-title">首次使用请先设置博客仓库根目录</div>
<p>这个路径只需要在当前设备配置一次,保存后后续会自动沿用。</p>
</div>
<button id="setupCta" class="setup-button" type="button">去设置</button>
</section>

这样,当用户第一次打开工具时:

  • 齿轮按钮上会有一个提示点
  • 页面顶部会显示“请先设置博客仓库根目录”的引导条
  • 用户点击“去设置”即可进入设置弹窗

启动时如何自动决定要不要显示首次引导

程序启动时会先调用 loadConfig()

async function loadConfig() {
const envelope = (await invoke("load_config")) as ConfigEnvelope;
applyConfigToForm(envelope.config);
applySetupState(envelope.config.repoRoot);
getEl<HTMLDivElement>("#configPath").textContent = envelope.configPath
? `config: ${envelope.configPath}`
: "config: 未找到配置文件";
if (envelope.config.repoRoot.trim()) {
setStatus("idle", "本地配置已加载");
} else {
setStatus("warn", "首次使用请先设置博客仓库根目录");
}
}

这里的判断逻辑非常清晰:

  • repoRoot:说明当前设备以前设置过
  • 没有 repoRoot:说明当前设备还没完成初始化

因此是否显示“首次引导”,完全取决于本机配置文件中的 repoRoot 是否为空。

为什么解析和转换前还要再做一层兜底校验

仅仅依靠 UI 上的提示还不够,因为用户可能:

  • 不看顶部引导条
  • 直接点击“解析预览”
  • 直接点击“开始转换”

所以我又加了一层运行时保护:

function ensureRepoRootConfigured(actionLabel: string) {
const repoRoot = getRepoRootValue();
applySetupState(repoRoot);
if (repoRoot) {
return true;
}
setStatus("warn", "请先设置博客仓库根目录");
reportText(`[Setup Required]\n- ${actionLabel}前请先在设置中填写博客仓库根目录。`);
setSettingsOpen(true);
return false;
}

在解析时调用:

if (!ensureRepoRootConfigured("解析")) {
state.lastAnalyze = null;
setImagePill();
setPreview(null);
return;
}

在转换时调用:

if (!ensureRepoRootConfigured("转换")) {
setConvertButtonPhase("error");
return;
}

这层保护的作用是:

  • 未配置时直接阻止操作
  • 自动打开设置窗口
  • 明确告诉用户问题出在“没有配置博客仓库根目录”

这样就不会出现“用户什么都没配,就直接开始转换,然后报一堆难懂错误”的情况。

Windows 权限:为什么程序可以写 AppData,而不是只能写自己目录

这里有一个很容易混淆的点:

1. 开发时的“工作区权限”

在开发辅助环境里,我看到的很多“只能读写工作区”的限制,其实是开发工具的沙箱限制。 这是为了保护系统,不代表最终打包出来的 exe 也只能这么做。

2. 真正 exe 的权限

我打包后的 Tauri 应用在 Windows 上运行时,本质上是一个普通的 Windows 用户态程序。 它的权限取决于:

  • 当前是谁启动了这个 exe
  • 当前 Windows 用户对哪些目录有读写权限
  • 是否需要管理员权限
  • Windows ACL / UAC 是否允许

也就是说,程序的访问边界不是“只能访问自己工作区”,而是:

当前 Windows 用户有权限访问的目录。

为什么 AppData 是合法且推荐的配置存储位置

对于一个普通 Windows 桌面应用,下面这些目录通常都是当前用户可写的:

  • %APPDATA%
  • %LOCALAPPDATA%
  • 当前用户自己的文档目录
  • 当前用户自己的桌面目录

而下面这些目录通常不能直接写,除非提权:

  • C:\Windows
  • C:\Program Files
  • C:\Program Files (x86)

所以把配置存到:

%APPDATA%\com.local.blog-format-tool\config.toml

并不是“越权写系统目录”,而是在一个当前用户本来就有权限写入的标准应用配置目录中保存配置。

这也是 Windows 桌面程序的常见做法。

为什么不把配置写在 exe 同目录

如果把配置放在 exe 同目录,会带来几个问题:

  1. 如果 exe 在 Program Files 下,普通用户可能没有写权限
  2. 程序更新或替换后,配置可能丢失
  3. 多用户共用同一台设备时,配置容易混在一起
  4. 不符合 Windows 桌面应用存放配置的常见习惯

因此,把配置写进 AppData 是更合理、更稳定的方案。

整体行为流程总结

我最终采用的方案可以总结为:

启动应用
→ 读取本机 config.toml
→ 检查 repoRoot 是否为空
如果为空:
→ 显示设置按钮小圆点
→ 显示顶部首次使用引导
→ 用户点击“去设置”进入设置弹窗
→ 保存后写入 %APPDATA%\com.local.blog-format-tool\config.toml
→ 后续启动自动沿用
如果不为空:
→ 隐藏首次使用提示
→ 直接使用本机已有配置
→ 正常进行解析 / 转换

同时,在运行时我又加了一层防护:

点击“解析”或“转换”
→ 如果 repoRoot 未配置
→ 阻止操作
→ 自动打开设置弹窗
→ 提示先完成初始化