<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.9.5">Jekyll</generator><link href="https://friesi23.icu/feed.xml" rel="self" type="application/atom+xml" /><link href="https://friesi23.icu/" rel="alternate" type="text/html" /><updated>2026-06-12T09:47:29+00:00</updated><id>https://friesi23.icu/feed.xml</id><title type="html">薯条与123</title><subtitle>随便写写, 随便玩玩, 随便搞搞
</subtitle><entry><title type="html">本地 AI 开发环境搭建 — VS Code Continue / Copilot MCP 插件配置实践</title><link href="https://friesi23.icu/post/202606/local-ai-vscode-continue-copilot-mcp-config" rel="alternate" type="text/html" title="本地 AI 开发环境搭建 — VS Code Continue / Copilot MCP 插件配置实践" /><published>2026-06-11T01:00:00+00:00</published><updated>2026-06-11T01:00:00+00:00</updated><id>https://friesi23.icu/post/202606/local-ai-vscode-continue-copilot-mcp-config</id><content type="html" xml:base="https://friesi23.icu/post/202606/local-ai-vscode-continue-copilot-mcp-config"><![CDATA[<p>最近一直在折腾本地 AI 编码助手的环境，从 <a href="https://ollama.com/">Ollama</a> 到 <a href="https://lmstudio.ai/">LM Studio</a>，再到 VS Code 的 <a href="https://continue.dev/">Continue</a> 和 <a href="https://github.com/features/copilot">Copilot</a> MCP 插件，零零散散踩了不少坑。
云端方案固然方便，但隐私、成本、定制化这些需求一上来，还是得自己搭一套本地链路。</p>

<p>因此在这里记录一下完整的配置过程：从硬件选型、本地模型部署、MCP 服务器接入，到 Continue 和 Copilot 插件的联动使用。</p>

<blockquote>
  <p><strong>版本说明</strong>：本文基于 VS Code 1.124.0（内置 Copilot）和 Continue 1.3.38 编写，插件更新较快，部分界面和配置项可能随版本变化。</p>
</blockquote>

<p>这篇文章分为两大部分：AI 编码助手配置和 MCP 服务器配置，两者同等重要——前者决定 AI 的能力上限，后者决定 AI 能调用哪些工具。
希望能帮到同样想在本机跑 AI 编码助手的朋友。</p>

<h2 id="1-为什么需要本地-ai--mcp">1. 为什么需要本地 AI + MCP</h2>

<p>云端 AI 编码助手（如 <a href="https://github.com/features/copilot">GitHub Copilot</a>、<a href="https://www.cursor.com/">Cursor</a> 等）已经非常强大了，但有几个场景我还是倾向于本地方案：</p>

<ul>
  <li><strong>隐私敏感项目</strong>：有些代码实在不愿意上传到第三方服务。</li>
  <li><strong>定制化工具链</strong>：通过 <a href="https://modelcontextprotocol.io/">MCP</a>（Model Context Protocol）把本地命令、数据库查询、项目特定知识暴露给 AI，这是云端方案做不到的。</li>
  <li><strong>成本可控</strong>：本地模型推理不产生 API 调用费用，对重度用户来说长期来看更划算。</li>
</ul>

<h2 id="2-机器配置与实际模型选型">2. 机器配置与实际模型选型</h2>

<p>最近选模型的时候踩了不少坑，最大的教训就是：别光看”参数量”这个数字，硬件瓶颈才是决定日常体验的关键。</p>

<p>本地跑模型，核心瓶颈有两个：一是内存容量（决定能装多大的模型），二是<a href="#glossary-bandwidth">内存带宽</a>（决定 <a href="#glossary-token">token</a> 吐出来的速度）。
下面以我手头的两台机器和实际使用的模型为例，结合硬件参数和体感体验给出选型建议。</p>

<h3 id="21-桌面机rtx-4090--i7-14700--64gb">2.1. 桌面机：RTX 4090 + i7-14700 + 64GB</h3>

<p>这台机器通过 LM Studio 暴露本地服务，是日常编码的主力。RTX 4090 的 24GB GDDR6X <a href="#glossary-vram">显存</a>配合约 1008 GB/s 的内存带宽，在消费级显卡里属于第一梯队。</p>

<p>我在这台机器上主要使用的模型：</p>

<table>
  <thead>
    <tr>
      <th>模型</th>
      <th><a href="#glossary-architecture">架构</a></th>
      <th>磁盘占用</th>
      <th><a href="#glossary-active-params">激活参数</a></th>
      <th>推理体验</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>qwen3.6 27b</td>
      <td>稠密</td>
      <td>17 GB</td>
      <td>27B</td>
      <td>流畅，日常主力</td>
    </tr>
    <tr>
      <td>qwen3-coder 30b</td>
      <td>MoE</td>
      <td>19 GB</td>
      <td>3.3B</td>
      <td>体感偏慢，不推荐</td>
    </tr>
    <tr>
      <td>gemma4 26b</td>
      <td>MoE</td>
      <td>17 GB</td>
      <td>3.8B</td>
      <td>流畅，MoE 优势明显</td>
    </tr>
    <tr>
      <td>gpt-oss 20b</td>
      <td>稠密</td>
      <td>~12 GB</td>
      <td>20B</td>
      <td>备选，够用</td>
    </tr>
  </tbody>
</table>

<p>这里有一个值得展开的坑：qwen3-coder 30b 虽然激活参数只有 3.3B，但体感上 token 生成速度并不理想。
原因不在于”激活参数少就快”——MoE 架构每层要选哪些”专家”参与计算，而且 30B 的总参数量意味着每次推理都要从显存读取全部专家权重，实际内存访问量远大于 3.3B 对应的理论值（一点私货：选模型时别被”激活参数”这个数字骗了，总参数对内存压力的影响更大）。</p>

<p>相比之下，gemma4 26b 同样是 MoE 架构（25.2B 总参 / 3.8B 激活），但磁盘占用更小（17 GB vs 19 GB），”专家选择”的开销相对更低，体感明显更流畅。</p>

<p><strong>强烈建议</strong>在 24GB 显存的机器上，优先选 17-20 GB 量级的模型，留出 4-7 GB 给上下文和运行时开销。
超过 20 GB 的模型虽然能塞进显存，但一旦上下文变长，就容易触发 <a href="#glossary-cpu-offload">CPU offload</a>，速度会断崖式下降。</p>

<h3 id="22-macbook-prom4-pro--48gb-统一内存">2.2. MacBook Pro：M4 Pro + 48GB <a href="#glossary-unified-memory">统一内存</a></h3>

<p>这台机器通过 Ollama 本地使用，适合移动办公和深度分析场景。M4 Pro 的统一内存带宽约 200 GB/s，只有 RTX 4090 的五分之一左右，但 48GB 容量是实打实的优势。</p>

<p>我在这台机器上实际使用的模型：</p>

<table>
  <thead>
    <tr>
      <th>模型</th>
      <th>架构</th>
      <th>磁盘占用</th>
      <th>激活参数</th>
      <th>推理体验</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>qwen3.6 35b-mlx</td>
      <td>稠密</td>
      <td>22 GB</td>
      <td>35B</td>
      <td>可用，MLX 格式有硬件加速</td>
    </tr>
    <tr>
      <td>qwen3-coder 30b</td>
      <td>MoE</td>
      <td>19 GB</td>
      <td>3.3B</td>
      <td>可用，适合代码深度分析</td>
    </tr>
    <tr>
      <td>deepseek-coder-v2 16b</td>
      <td>MoE</td>
      <td>8.9 GB</td>
      <td>~2.4B</td>
      <td>流畅，日常编码够用</td>
    </tr>
    <tr>
      <td>codestral 22b</td>
      <td>稠密</td>
      <td>13 GB</td>
      <td>22B</td>
      <td>流畅，代码生成质量高</td>
    </tr>
    <tr>
      <td>hermes3 8b</td>
      <td>稠密</td>
      <td>4.7 GB</td>
      <td>8B</td>
      <td>非常流畅，轻量任务</td>
    </tr>
    <tr>
      <td>qwen2.5-coder 1.5b</td>
      <td>稠密</td>
      <td>986 MB</td>
      <td>1.5B</td>
      <td>极快，补全专用</td>
    </tr>
    <tr>
      <td>nomic-embed-text</td>
      <td>嵌入</td>
      <td>274 MB</td>
      <td>-</td>
      <td>向量检索，几乎无感</td>
    </tr>
  </tbody>
</table>

<p>M4 Pro 的内存带宽虽然弱，但 <a href="#glossary-mlx">MLX 格式</a>对 Apple Silicon 有原生优化，
qwen3.6 35b-mlx 的推理体验比同量级 <a href="#glossary-gguf">GGUF 格式</a>好不少。
对于需要更强推理能力的场景（比如复杂架构设计、多文件重构），
35B 稠密模型在 MacBook 上的表现反而比 4090 上跑 27B 更有深度。</p>

<h3 id="23-关于-cpu-offload-的取舍">2.3. 关于 CPU offload 的取舍</h3>

<p>当模型大小超过 GPU 显存时，推理框架会把部分计算任务挪到系统内存，
由 CPU 分担。这个策略的代价很大：</p>

<ul>
  <li><strong>RTX 4090 + 64GB DDR5</strong>：GPU 和系统内存之间靠 PCIe 总线通信，理论带宽约 64 GB/s，只有显存带宽的六分之一。一旦触发 offload，
token 生成速度会从每秒几十个降到个位数。</li>
  <li><strong>M4 Pro 统一内存</strong>：GPU 和 CPU 共享同一条内存总线，不存在”跨设备搬运数据”的问题，offload 的惩罚远小于桌面方案。</li>
</ul>

<p>因此我的经验法则是：</p>

<ul>
  <li>桌面端：模型 + 上下文尽量控制在显存以内，宁可换小一点的模型也不要触发 offload。</li>
  <li>Mac 端：统一内存架构下 offload 惩罚较小，可以大胆用更大的模型，用容量换推理深度。</li>
</ul>

<h3 id="24-跨设备工作流">2.4. 跨设备工作流</h3>

<p>结合两台机器的特点，我的实际分工是：</p>

<ul>
  <li><strong>桌面端（4090）</strong>：qwen3.6 27b 做日常编码主力，gemma4 26b 做备选。响应速度快，适合高频交互。</li>
  <li><strong>MacBook（M4 Pro）</strong>：qwen3.6 35b-mlx 做深度分析，deepseek-coder-v2 16b 做移动办公时的日常编码。</li>
  <li><strong>轻量任务</strong>：hermes3 8b 或 qwen2.5-coder 1.5b，两台机器都能秒回，适合快速问答和简单补全。</li>
</ul>

<p>两者通过同一套 VS Code 配置（Continue + MCP）无缝衔接，只需切换 <code class="language-plaintext highlighter-rouge">apiBase</code> 地址。</p>

<h2 id="3-本地模型部署方案选型">3. 本地模型部署方案选型</h2>

<p>在配置插件之前，需要先跑通本地模型服务。目前个人开发者最常用的方案是 Ollama 和 LM Studio，下面将两者的适用范围和上手难度做一个对比。</p>

<h3 id="31-ollama">3.1. Ollama</h3>

<p><a href="https://ollama.com/">Ollama</a> 是目前最轻量的本地模型部署工具，支持 macOS 和 Windows 平台。安装方式很简单：</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># macOS 安装</span>
brew <span class="nb">install </span>ollama

<span class="c"># Windows 安装（winget）</span>
winget <span class="nb">install </span>Ollama.Ollama

<span class="c"># Windows 安装（choco）</span>
choco <span class="nb">install </span>ollama
</code></pre></div></div>

<p>安装后一条命令即可拉取模型并启动服务（默认 <code class="language-plaintext highlighter-rouge">11434</code> 端口）：</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ollama pull qwen2.5:14b
ollama serve
</code></pre></div></div>

<p>Ollama 的模型库覆盖了主流开源模型，量化版本（如 <code class="language-plaintext highlighter-rouge">q4_k_m</code>）对显存要求也比较友好。</p>

<p>我从实际使用角度说几个体会：Ollama 虽然提供了 GUI，但功能比较简陋，可配置项不多。
如果想自定义模型（比如调整系统提示词或改变采样参数），得手写 <code class="language-plaintext highlighter-rouge">Modelfile</code> 然后 <code class="language-plaintext highlighter-rouge">ollama create</code> 重新生成模型，server 端也不能通过界面调参，只能命令行附加环境变量。
但这反过来也让它很适合自动化场景：CI 流水线里一条 <code class="language-plaintext highlighter-rouge">ollama pull</code> 就能拉起服务，和脚本联动也很方便，不需要额外处理 GUI 进程。</p>

<h3 id="32-lm-studio">3.2. LM Studio</h3>

<p><a href="https://lmstudio.ai/">LM Studio</a> 同样支持 macOS 和 Windows，提供了一个完整的 GUI 界面，模型下载、加载、对话都在图形界面里完成。</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># macOS 安装</span>
brew <span class="nb">install</span> <span class="nt">--cask</span> lm-studio

<span class="c"># Windows 安装（winget）</span>
winget <span class="nb">install </span>LM-Studio.lm-studio

<span class="c"># Windows 安装（choco）</span>
choco <span class="nb">install </span>lm-studio
</code></pre></div></div>

<p>和 Ollama 相反，LM Studio 的参数调优可以在界面里通过滑块完成，不需要手写配置文件。如果想对比不同模型的效果，直接在 GUI 里切换就行。</p>

<p>但我实际使用中也有几个感受：LM Studio 的 GUI 虽然方便，但也带来了额外开销，资源占用比 Ollama 稍重。
更关键的是，把它集成到 CI 或自动化脚本里不太容易——比如我想通过脚本控制模型加载、调整参数、再启动服务，LM Studio 没有像 Ollama 那样干净的命令行接口，和 shell 脚本联动的成本比较高。</p>

<h3 id="33-两者的取舍">3.3. 两者的取舍</h3>

<p>两者都提供了 server 能力，但设计取向不同。Ollama 偏向命令行和自动化，CI 集成友好，自定义程度高但需要手动写 <code class="language-plaintext highlighter-rouge">Modelfile</code> 并重新创建模型。LM Studio 偏向交互体验，GUI 可配置项多，但高度自定义（比如和脚本联动）相对不容易。</p>

<p>我手头的分工是：桌面端（4090）用 LM Studio 暴露服务，因为日常编码时通过 GUI 调参更直观；MacBook 上用 Ollama，因为移动场景下我更看重轻量，而且 <code class="language-plaintext highlighter-rouge">ollama pull</code> 在终端里一条命令就能搞定。</p>

<p>两者暴露的 API 端口略有不同：Ollama 默认 <code class="language-plaintext highlighter-rouge">http://localhost:11434</code>，LM Studio 默认 <code class="language-plaintext highlighter-rouge">http://localhost:1234</code>（OpenAI 兼容模式）。
后续章节以 Ollama 为例，但 LM Studio 的配置思路完全一致，只需替换 <code class="language-plaintext highlighter-rouge">apiBase</code> 地址即可。</p>

<h2 id="4-continue-插件配置">4. Continue 插件配置</h2>

<p><a href="https://continue.dev/">Continue</a> 是一个开源的 AI 编码助手插件，支持接入任意模型提供商（包括本地 <a href="https://ollama.com/">Ollama</a> 和 <a href="https://lmstudio.ai/">LM Studio</a>）。这是本文 AI 配置部分的核心。</p>

<h3 id="41-安装与基础配置">4.1. 安装与基础配置</h3>

<p>在 VS Code 扩展市场安装 <code class="language-plaintext highlighter-rouge">Continue</code> 后，配置文件通常位于 <code class="language-plaintext highlighter-rouge">~/.continue/</code> 目录或工作区根目录的 <code class="language-plaintext highlighter-rouge">.continue/config.yaml</code>。</p>

<h3 id="42-模型配置">4.2. 模型配置</h3>

<p>Continue 的模型配置是整篇配置的核心。结合前文两台机器的硬件特点和模型选型，下面将按角色拆分配置。</p>

<h4 id="421-chat-模型">4.2.1. Chat 模型</h4>

<p>Chat 模型用于对话面板的深度交互，是日常使用频率最高的角色。我根据两台机器分别配置了本地和云端（Home Cloud）两套模型：</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">models</span><span class="pi">:</span>
  <span class="c1"># —— 本地 Ollama（MacBook M4 Pro）——</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Qwen3.6 35B (Local)</span>
    <span class="na">provider</span><span class="pi">:</span> <span class="s">ollama</span>
    <span class="na">model</span><span class="pi">:</span> <span class="s">qwen3.6:35b-mlx</span>
    <span class="na">roles</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">chat</span>
    <span class="na">capabilities</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">tool_use</span>
      <span class="pi">-</span> <span class="s">image_input</span>
    <span class="na">defaultCompletionOptions</span><span class="pi">:</span>
      <span class="na">contextLength</span><span class="pi">:</span> <span class="m">65536</span>

  <span class="c1"># —— Home Cloud LM Studio（桌面 RTX 4090）——</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Qwen3.6 27B (Home Cloud)</span>
    <span class="na">provider</span><span class="pi">:</span> <span class="s">lmstudio</span>
    <span class="na">model</span><span class="pi">:</span> <span class="s">qwen/qwen3.6-27b</span>
    <span class="na">apiBase</span><span class="pi">:</span> <span class="s">http://&lt;internal-hostname&gt;:1234/v1</span>
    <span class="na">apiKey</span><span class="pi">:</span> <span class="s">sk-lm-&lt;PLACEHOLDER&gt;</span>
    <span class="na">requestOptions</span><span class="pi">:</span>
      <span class="na">timeout</span><span class="pi">:</span> <span class="m">120000</span>
    <span class="na">roles</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">chat</span>
    <span class="na">capabilities</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">tool_use</span>
      <span class="pi">-</span> <span class="s">image_input</span>

  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Qwen3.6 35B (Home Cloud)</span>
    <span class="na">provider</span><span class="pi">:</span> <span class="s">lmstudio</span>
    <span class="na">model</span><span class="pi">:</span> <span class="s">qwen/qwen3.6-31b-qat</span>
    <span class="na">apiBase</span><span class="pi">:</span> <span class="s">http://&lt;internal-hostname&gt;:1234/v1</span>
    <span class="na">apiKey</span><span class="pi">:</span> <span class="s">sk-lm-&lt;PLACEHOLDER&gt;</span>
    <span class="na">requestOptions</span><span class="pi">:</span>
      <span class="na">timeout</span><span class="pi">:</span> <span class="m">120000</span>
    <span class="na">defaultCompletionOptions</span><span class="pi">:</span>
      <span class="na">contextLength</span><span class="pi">:</span> <span class="m">65536</span>
    <span class="na">roles</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">chat</span>
    <span class="na">capabilities</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">tool_use</span>
      <span class="pi">-</span> <span class="s">image_input</span>

  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Gemma4 26B (Home Cloud)</span>
    <span class="na">provider</span><span class="pi">:</span> <span class="s">lmstudio</span>
    <span class="na">model</span><span class="pi">:</span> <span class="s">google/gemma-4-26b-a4b-qat</span>
    <span class="na">apiBase</span><span class="pi">:</span> <span class="s">http://&lt;internal-hostname&gt;:1234/v1</span>
    <span class="na">apiKey</span><span class="pi">:</span> <span class="s">sk-lm-&lt;PLACEHOLDER&gt;</span>
    <span class="na">requestOptions</span><span class="pi">:</span>
      <span class="na">timeout</span><span class="pi">:</span> <span class="m">120000</span>
    <span class="na">defaultCompletionOptions</span><span class="pi">:</span>
      <span class="na">contextLength</span><span class="pi">:</span> <span class="m">32768</span>
    <span class="na">roles</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">chat</span>
    <span class="na">capabilities</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">tool_use</span>
      <span class="pi">-</span> <span class="s">image_input</span>

  <span class="c1"># —— 其他角色模型（edit/apply/autocomplete/embed/rerank）见下文 ——</span>
  <span class="c1"># - name: ...</span>
  <span class="c1">#   ...</span>
</code></pre></div></div>

<p>几个配置细节值得说明：</p>

<ul>
  <li><strong><code class="language-plaintext highlighter-rouge">roles: [chat]</code></strong>：明确声明该模型用于对话角色。Continue 支持 <code class="language-plaintext highlighter-rouge">chat</code> / <code class="language-plaintext highlighter-rouge">edit</code> / <code class="language-plaintext highlighter-rouge">apply</code> / <code class="language-plaintext highlighter-rouge">autocomplete</code> / <code class="language-plaintext highlighter-rouge">embed</code> / <code class="language-plaintext highlighter-rouge">rerank</code> 等角色，一个模型可以兼任多个角色。</li>
  <li><strong><code class="language-plaintext highlighter-rouge">capabilities</code></strong>：<code class="language-plaintext highlighter-rouge">tool_use</code> 表示模型支持工具调用（即 MCP 工具），<code class="language-plaintext highlighter-rouge">image_input</code> 表示支持图片输入。
不是所有模型都支持这些能力，配置时根据模型实际能力勾选即可。</li>
  <li><strong><code class="language-plaintext highlighter-rouge">contextLength</code></strong>：上下文窗口大小。qwen3.6 系列原生支持 65536 的上下文，而 gemma4 26b 我设为 32768——
桌面端显存有限，上下文太长容易触发 CPU offload（见 <a href="#23-关于-cpu-offload-的取舍">2.3 节</a>）。</li>
  <li><strong><code class="language-plaintext highlighter-rouge">requestOptions.timeout</code></strong>：LM Studio 远程调用设置了 120 秒超时，因为桌面端模型较大，复杂问题推理时间可能较长。</li>
</ul>

<h4 id="422-edit-和-apply-模型">4.2.2. Edit 和 Apply 模型</h4>

<p>Edit 角色负责在编辑器中直接修改代码，Apply 角色将修改应用到文件。这两个角色对代码理解能力要求较高，我选了 qwen3-coder 30b：</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">models</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Qwen3 Coder 30B (Local)</span>
    <span class="na">provider</span><span class="pi">:</span> <span class="s">ollama</span>
    <span class="na">model</span><span class="pi">:</span> <span class="s">qwen3-coder:30b</span>
    <span class="na">roles</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">chat</span>
      <span class="pi">-</span> <span class="s">edit</span>
      <span class="pi">-</span> <span class="s">apply</span>
    <span class="na">capabilities</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">tool_use</span>
    <span class="na">defaultCompletionOptions</span><span class="pi">:</span>
      <span class="na">temperature</span><span class="pi">:</span> <span class="m">0.7</span>
      <span class="na">contextLength</span><span class="pi">:</span> <span class="m">32768</span>

  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Qwen3 Coder 30B (Home Cloud)</span>
    <span class="na">provider</span><span class="pi">:</span> <span class="s">lmstudio</span>
    <span class="na">model</span><span class="pi">:</span> <span class="s">qwen/qwen3-coder-30b</span>
    <span class="na">apiBase</span><span class="pi">:</span> <span class="s">http://&lt;internal-hostname&gt;:1234/v1</span>
    <span class="na">apiKey</span><span class="pi">:</span> <span class="s">sk-lm-&lt;PLACEHOLDER&gt;</span>
    <span class="na">requestOptions</span><span class="pi">:</span>
      <span class="na">timeout</span><span class="pi">:</span> <span class="m">120000</span>
    <span class="na">defaultCompletionOptions</span><span class="pi">:</span>
      <span class="na">contextLength</span><span class="pi">:</span> <span class="m">32768</span>
    <span class="na">roles</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">chat</span>
      <span class="pi">-</span> <span class="s">edit</span>
      <span class="pi">-</span> <span class="s">apply</span>
    <span class="na">capabilities</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">tool_use</span>

  <span class="c1"># —— 其他角色模型（chat/autocomplete/embed/rerank）见上文/下文 ——</span>
  <span class="c1"># - name: ...</span>
  <span class="c1">#   ...</span>
</code></pre></div></div>

<p>qwen3-coder 在代码生成和修改方面表现突出，虽然前文提到它在 4090 上的 token 生成速度一般，
但 edit/apply 场景不需要高频交互，深度更重要。<code class="language-plaintext highlighter-rouge">temperature: 0.7</code> 让输出在创造性和稳定性之间取得平衡。</p>

<h4 id="423-自动补全模型">4.2.3. 自动补全模型</h4>

<p>自动补全对响应速度要求极高，模型太大反而影响体验。我选了 qwen2.5-coder 1.5b——虽然只有 1.5B 参数，
但针对代码任务做了专门训练，补全质量完全够用：</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">models</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Qwen2.5 Coder 1.5B (Local)</span>
    <span class="na">provider</span><span class="pi">:</span> <span class="s">ollama</span>
    <span class="na">model</span><span class="pi">:</span> <span class="s">qwen2.5-coder:1.5b</span>
    <span class="na">roles</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">autocomplete</span>
    <span class="na">capabilities</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">tool_use</span>
    <span class="na">autocompleteOptions</span><span class="pi">:</span>
      <span class="na">disable</span><span class="pi">:</span> <span class="no">false</span>
      <span class="na">maxPromptTokens</span><span class="pi">:</span> <span class="m">1024</span>
      <span class="na">debounceDelay</span><span class="pi">:</span> <span class="m">250</span>
      <span class="na">modelTimeout</span><span class="pi">:</span> <span class="m">150</span>
      <span class="na">maxSuffixPercentage</span><span class="pi">:</span> <span class="m">0.2</span>
      <span class="na">prefixPercentage</span><span class="pi">:</span> <span class="m">0.3</span>
      <span class="na">onlyMyCode</span><span class="pi">:</span> <span class="no">true</span>

  <span class="c1"># —— 其他角色模型（chat/edit/apply/embed/rerank）见上文/下文 ——</span>
  <span class="c1"># - name: ...</span>
  <span class="c1">#   ...</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">autocompleteOptions</code> 的几个关键参数：</p>

<ul>
  <li><strong><code class="language-plaintext highlighter-rouge">maxPromptTokens: 1024</code></strong>：限制每次补全请求的上下文大小，避免把整个文件都发给模型。</li>
  <li><strong><code class="language-plaintext highlighter-rouge">debounceDelay: 250</code></strong>：输入停止 250ms 后再触发补全，避免打字过程中频繁请求。</li>
  <li><strong><code class="language-plaintext highlighter-rouge">modelTimeout: 150</code></strong>：150ms 超时——补全要快，超时了就放弃，别让用户等。</li>
  <li><strong><code class="language-plaintext highlighter-rouge">onlyMyCode: true</code></strong>：只索引用户自己的代码文件，不扫描 <code class="language-plaintext highlighter-rouge">node_modules</code> 等第三方库，减少噪音。</li>
</ul>

<h4 id="424-嵌入和重排序模型">4.2.4. 嵌入和重排序模型</h4>

<p>嵌入模型用于代码库的向量检索，重排序模型对检索结果进行精排。两者配合可以让 AI 更准确地找到项目中的相关代码：</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">models</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Nomic Embed Text V1.5 (Local)</span>
    <span class="na">provider</span><span class="pi">:</span> <span class="s">ollama</span>
    <span class="na">model</span><span class="pi">:</span> <span class="s">nomic-embed-text:v1.5</span>
    <span class="na">roles</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">embed</span>
    <span class="na">embedOptions</span><span class="pi">:</span>
      <span class="na">maxChunkSize</span><span class="pi">:</span> <span class="m">512</span>
      <span class="na">maxBatchSize</span><span class="pi">:</span> <span class="m">4</span>

  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Huggingface-tei Reranker (Local)</span>
    <span class="na">provider</span><span class="pi">:</span> <span class="s">huggingface-tei</span>
    <span class="na">model</span><span class="pi">:</span> <span class="s">tei</span>
    <span class="na">apiBase</span><span class="pi">:</span> <span class="s">http://localhost:18082</span>
    <span class="na">apiKey</span><span class="pi">:</span> <span class="s">&lt;PLACEHOLDER&gt;</span>
    <span class="na">roles</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">rerank</span>

  <span class="c1"># —— 其他角色模型（chat/edit/apply/autocomplete）见上文 ——</span>
  <span class="c1"># - name: ...</span>
  <span class="c1">#   ...</span>
</code></pre></div></div>

<p>nomic-embed-text 只有 274 MB，几乎不占资源，但检索效果出乎意料地好。
<a href="https://huggingface.co/docs/text-embeddings-inference/en/index">Huggingface TEI</a>（Text Embeddings Inference）重排序服务跑在本地 <code class="language-plaintext highlighter-rouge">18082</code> 端口，
对检索结果做二次排序后，AI 引用代码的准确率有明显提升。</p>

<h3 id="43-上下文提供者配置">4.3. 上下文提供者配置</h3>

<p>Continue 的 <code class="language-plaintext highlighter-rouge">context</code> 配置决定了 AI 能访问哪些本地信息源。以下是我的配置：</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">context</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">provider</span><span class="pi">:</span> <span class="s">code</span>
  <span class="pi">-</span> <span class="na">provider</span><span class="pi">:</span> <span class="s">diff</span>
  <span class="pi">-</span> <span class="na">provider</span><span class="pi">:</span> <span class="s">terminal</span>
  <span class="pi">-</span> <span class="na">provider</span><span class="pi">:</span> <span class="s">open</span>
    <span class="na">params</span><span class="pi">:</span>
      <span class="na">onlyPinned</span><span class="pi">:</span> <span class="no">true</span>
  <span class="pi">-</span> <span class="na">provider</span><span class="pi">:</span> <span class="s">clipboard</span>
  <span class="pi">-</span> <span class="na">provider</span><span class="pi">:</span> <span class="s">tree</span>
  <span class="pi">-</span> <span class="na">provider</span><span class="pi">:</span> <span class="s">debugger</span>
    <span class="na">params</span><span class="pi">:</span>
      <span class="na">stackDepth</span><span class="pi">:</span> <span class="m">3</span>
  <span class="pi">-</span> <span class="na">provider</span><span class="pi">:</span> <span class="s">repo-map</span>
    <span class="na">params</span><span class="pi">:</span>
      <span class="na">includeSignatures</span><span class="pi">:</span> <span class="no">false</span>
  <span class="pi">-</span> <span class="na">provider</span><span class="pi">:</span> <span class="s">os</span>
</code></pre></div></div>

<p>这些上下文提供者的作用：</p>

<table>
  <thead>
    <tr>
      <th>提供者</th>
      <th>作用</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">code</code></td>
      <td>引用当前工作区的代码文件</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">diff</code></td>
      <td>提供未提交的代码变更</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">terminal</code></td>
      <td>读取终端输出，帮助诊断命令执行结果</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">open</code></td>
      <td>当前打开的文件（<code class="language-plaintext highlighter-rouge">onlyPinned: true</code> 只包含固定标签页）</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">clipboard</code></td>
      <td>剪贴板内容，方便粘贴后直接让 AI 处理</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">tree</code></td>
      <td>项目目录结构</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">debugger</code></td>
      <td>调试器状态，<code class="language-plaintext highlighter-rouge">stackDepth: 3</code> 限制栈深度</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">repo-map</code></td>
      <td>仓库符号索引，<code class="language-plaintext highlighter-rouge">includeSignatures: false</code> 减少开销</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">os</code></td>
      <td>操作系统信息</td>
    </tr>
  </tbody>
</table>

<p><strong>强烈建议</strong>不要把所有上下文提供者都打开——每个提供者都会增加 prompt 的 token 消耗，
按需启用即可。比如 <code class="language-plaintext highlighter-rouge">debugger</code> 只在调试场景下有用，平时可以关掉。</p>

<h3 id="44-跨设备无缝切换">4.4. 跨设备无缝切换</h3>

<p>两套模型配置（本地 Ollama + Home Cloud LM Studio）的好处是：</p>

<ul>
  <li><strong>在 MacBook 上</strong>：优先使用本地 Ollama 模型，不依赖网络。</li>
  <li><strong>在桌面端</strong>：通过 <code class="language-plaintext highlighter-rouge">apiBase</code> 连接桌面 LM Studio 服务，用 4090 的算力。</li>
  <li><strong>桌面机宕机时</strong>：MacBook 上的本地模型可以无缝接管，不影响工作。</li>
</ul>

<p>切换不需要改配置——Continue 会在 Chat 面板里列出所有可用模型，下拉菜单选一个就行。</p>

<h3 id="45-核心功能">4.5. 核心功能</h3>

<ul>
  <li><strong>Chat 面板</strong>：在编辑器侧边栏直接与模型对话，支持引用当前文件内容和选中代码。</li>
  <li><strong>Tab 自动补全</strong>：基于本地模型的代码补全，配置 <code class="language-plaintext highlighter-rouge">autocomplete</code> 角色后生效。</li>
  <li><strong>自定义指令</strong>：通过 <code class="language-plaintext highlighter-rouge">.continue/rules</code> 定义项目级编码规范，让 AI 输出符合你的风格。</li>
</ul>

<h3 id="46-mcp-服务器配置">4.6. MCP 服务器配置</h3>

<p>Continue 的 MCP 服务器配置独立于 VS Code 用户级 <code class="language-plaintext highlighter-rouge">mcp.json</code>，位于 <code class="language-plaintext highlighter-rouge">~/.continue/mcpServers/vscode-mcp.json</code>。
下面是我实际使用的两个 MCP 服务器：</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ </span><span class="nb">cat</span> ~/.continue/mcpServers/vscode-mcp.json
<span class="o">{</span>
  <span class="s2">"mcpServers"</span>: <span class="o">{</span>
    <span class="s2">"filesystem"</span>: <span class="o">{</span>
      <span class="s2">"args"</span>: <span class="o">[</span>
        <span class="s2">"-y"</span>,
        <span class="s2">"@modelcontextprotocol/server-filesystem"</span>,
        <span class="s2">"."</span>
      <span class="o">]</span>,
      <span class="s2">"autoApprove"</span>: <span class="s2">"true"</span>,
      <span class="s2">"command"</span>: <span class="s2">"npx"</span>
    <span class="o">}</span>,
    <span class="s2">"context7"</span>: <span class="o">{</span>
      <span class="s2">"name"</span>: <span class="s2">"context7"</span>,
      <span class="s2">"command"</span>: <span class="s2">"npx"</span>,
      <span class="s2">"args"</span>: <span class="o">[</span>
        <span class="s2">"-y"</span>,
        <span class="s2">"@upstash/context7-mcp"</span>
      <span class="o">]</span>
    <span class="o">}</span>
  <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<ul>
  <li><code class="language-plaintext highlighter-rouge">filesystem</code> 服务器让 AI 能直接读写当前工作区文件。</li>
  <li><code class="language-plaintext highlighter-rouge">context7</code> 则提供了额外的上下文检索能力，适合在大型项目中快速定位相关代码。</li>
</ul>

<p>注意 Continue 的 MCP 配置和 VS Code 内置的 <code class="language-plaintext highlighter-rouge">mcp.json</code> 是两套独立体系，前者给 Continue 用，后者给 Copilot 用。
两者可以同时启用，AI 工具会根据当前使用的插件选择对应的 MCP 服务器。</p>

<h2 id="5-copilot-模型与-mcp-配置">5. Copilot 模型与 MCP 配置</h2>

<p><a href="https://github.com/features/copilot">GitHub Copilot</a> 从 2025 年起开始支持 <a href="https://modelcontextprotocol.io/">MCP</a> 服务器接入，这意味着你可以将本地工具直接暴露给 Copilot。
和 Continue 类似，Copilot 的配置也分为两部分：先配置模型（config），再配置 MCP 服务器。</p>

<h3 id="51-config-配置">5.1. Config 配置</h3>

<p>Copilot 的模型配置现在可以通过 VS Code 内置的 UI 界面完成，无需手动编辑 JSON 文件。
具体流程如下：</p>

<ol>
  <li>点开 Copilot Chat 聊天输入框底部的模型选择下拉菜单，在列表最下方的 “Other” 选项旁点击 <strong>齿轮图标</strong>（Manage Language Models）。</li>
  <li>
    <p>在打开的界面中点击右上角的 <strong>“Add Models”</strong> 按钮，并在下拉菜单中选择 <strong>“Custom Endpoint”</strong>。</p>

    <p><img src="https://cdn.jsdelivr.net/gh/FriesI23/blog-image@master/img/2026-06-11-local-ai-vscode-continue-copilot-mcp-config-pic-01.png" alt="copilot-custom-model" width="600" /></p>
  </li>
  <li>输入模型的群组名称（Group Name，如 LM-Studio）并回车。</li>
  <li>随后输入该端点的显示名称（Display Name）并回车，接着系统会提示输入 API Key（由于是本地 LM Studio，这里可以随便输入任意非空字符串，比如 “lm-studio” 或 “not-needed”）并回车。</li>
  <li>接着选择 API 类型，本地 LM Studio 通常选择 <strong>“Chat Completions”</strong>。</li>
  <li>填完这些后，VS Code 会自动打开 <code class="language-plaintext highlighter-rouge">chatLanguageModels.json</code> 配置文件。你只需在 <code class="language-plaintext highlighter-rouge">models</code> 数组中将 <code class="language-plaintext highlighter-rouge">"url"</code> 改为 <code class="language-plaintext highlighter-rouge">http://localhost:1234/v1</code>，并将 <code class="language-plaintext highlighter-rouge">"id"</code> 改为你在 LM Studio 中加载的具体模型 ID，保存并关闭文件即可。</li>
  <li>回到聊天面板的模型下拉菜单中，选中刚刚添加的本地模型开始使用。</li>
</ol>

<p>配置完成后，VS Code 会将配置写入 <code class="language-plaintext highlighter-rouge">~/Library/Application Support/Code/User/chatLanguageModels.json</code>（macOS）。
一个典型的配置文件如下：</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">[</span><span class="w">
  </span><span class="p">{</span><span class="w">
    </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Copilot"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"settings"</span><span class="p">:</span><span class="w"> </span><span class="p">{},</span><span class="w">
    </span><span class="nl">"vendor"</span><span class="p">:</span><span class="w"> </span><span class="s2">"copilot"</span><span class="w">
  </span><span class="p">},</span><span class="w">
  </span><span class="p">{</span><span class="w">
    </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Ollama"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"url"</span><span class="p">:</span><span class="w"> </span><span class="s2">"http://localhost:11434"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"vendor"</span><span class="p">:</span><span class="w"> </span><span class="s2">"ollama"</span><span class="w">
  </span><span class="p">},</span><span class="w">
  </span><span class="err">//</span><span class="w"> </span><span class="err">LM-Studio</span><span class="w">
  </span><span class="p">{</span><span class="w">
    </span><span class="nl">"apiKey"</span><span class="p">:</span><span class="w"> </span><span class="s2">"&lt;PLACEHOLDER&gt;"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"apiType"</span><span class="p">:</span><span class="w"> </span><span class="s2">"chat-completions"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"models"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
      </span><span class="p">{</span><span class="w">
        </span><span class="nl">"id"</span><span class="p">:</span><span class="w"> </span><span class="s2">"google/gemma-4-31b-qat"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"maxInputTokens"</span><span class="p">:</span><span class="w"> </span><span class="mi">32000</span><span class="p">,</span><span class="w">
        </span><span class="nl">"maxOutputTokens"</span><span class="p">:</span><span class="w"> </span><span class="mi">16000</span><span class="p">,</span><span class="w">
        </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Gemma4 31B"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"toolCalling"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span><span class="w">
        </span><span class="nl">"url"</span><span class="p">:</span><span class="w"> </span><span class="s2">"http://&lt;internal-hostname&gt;:1234/v1"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"vision"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
      </span><span class="p">},</span><span class="w">
      </span><span class="p">{</span><span class="w">
        </span><span class="nl">"id"</span><span class="p">:</span><span class="w"> </span><span class="s2">"qwen/qwen3.6-27b"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"maxInputTokens"</span><span class="p">:</span><span class="w"> </span><span class="mi">64000</span><span class="p">,</span><span class="w">
        </span><span class="nl">"maxOutputTokens"</span><span class="p">:</span><span class="w"> </span><span class="mi">16000</span><span class="p">,</span><span class="w">
        </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Qwen3.6 27B"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"toolCalling"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span><span class="w">
        </span><span class="nl">"url"</span><span class="p">:</span><span class="w"> </span><span class="s2">"http://&lt;internal-hostname&gt;:1234/v1"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"vision"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span><span class="w">
        </span><span class="nl">"defaultCompletionOptions"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
          </span><span class="nl">"temperature"</span><span class="p">:</span><span class="w"> </span><span class="mf">0.2</span><span class="p">,</span><span class="w">
          </span><span class="nl">"topP"</span><span class="p">:</span><span class="w"> </span><span class="mf">0.9</span><span class="w">
        </span><span class="p">}</span><span class="w">
      </span><span class="p">}</span><span class="w">
    </span><span class="p">]</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">]</span><span class="w">
</span></code></pre></div></div>

<p>几个配置细节值得说明：</p>

<ul>
  <li><strong><code class="language-plaintext highlighter-rouge">vendor: "copilot"</code></strong>：GitHub 官方的云端模型服务，无需额外配置 API Key，开箱即用。</li>
  <li><strong><code class="language-plaintext highlighter-rouge">vendor: "ollama"</code></strong>：本地 Ollama 服务，只需指定 <code class="language-plaintext highlighter-rouge">url</code> 即可接入，和 Continue 的 Ollama 配置思路一致。</li>
  <li><strong><code class="language-plaintext highlighter-rouge">vendor: "customendpoint"</code></strong>：自定义端点（如 LM Studio），需要配置 <code class="language-plaintext highlighter-rouge">apiKey</code>、<code class="language-plaintext highlighter-rouge">apiType</code> 以及具体的模型列表。</li>
  <li><strong><code class="language-plaintext highlighter-rouge">toolCalling</code></strong>：标记该模型支持工具调用（即 MCP 工具）。只有开启这个选项，Copilot 才能调用 MCP 服务器暴露的工具。</li>
  <li><strong><code class="language-plaintext highlighter-rouge">vision</code></strong>：标记该模型支持图片输入，适合多模态场景。</li>
  <li><strong><code class="language-plaintext highlighter-rouge">defaultCompletionOptions</code></strong>：采样参数，如 <code class="language-plaintext highlighter-rouge">temperature</code> 和 <code class="language-plaintext highlighter-rouge">topP</code>。<code class="language-plaintext highlighter-rouge">temperature: 0.2</code> 让输出更稳定，适合代码生成场景。</li>
</ul>

<p>我的实际配置中，桌面端（4090）通过 Custom Endpoint 接入了 LM Studio 上的 qwen3.6 27b 和 gemma4 31b，
MacBook 上则通过 Ollama 接入了本地的 qwen3.6 35b-mlx。切换模型时，在 Copilot Chat 面板的下拉菜单里选一个就行——</p>

<h3 id="52-mcp-服务器配置">5.2. MCP 服务器配置</h3>

<p>Copilot 的 MCP 能力通过 VS Code 用户级 <code class="language-plaintext highlighter-rouge">mcp.json</code> 实现，当 MCP 服务器启动后，Copilot 会自动发现可用工具。
没什么好说的，配好 <code class="language-plaintext highlighter-rouge">mcp.json</code> 就能用。</p>

<p>一个典型的 <code class="language-plaintext highlighter-rouge">mcp.json</code> 如下：</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"servers"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"github-mcp"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"http"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"url"</span><span class="p">:</span><span class="w"> </span><span class="s2">"https://example.com/mcp/"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"gallery"</span><span class="p">:</span><span class="w"> </span><span class="s2">"https://example.com/gallery"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"version"</span><span class="p">:</span><span class="w"> </span><span class="s2">"1.0.0"</span><span class="w">
    </span><span class="p">},</span><span class="w">
    </span><span class="nl">"drawio"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nl">"command"</span><span class="p">:</span><span class="w"> </span><span class="s2">"npx"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"args"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"-y"</span><span class="p">,</span><span class="w"> </span><span class="s2">"@drawio/mcp"</span><span class="p">]</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">},</span><span class="w">
  </span><span class="nl">"inputs"</span><span class="p">:</span><span class="w"> </span><span class="p">[]</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>配置分为两类启动方式：</p>

<ul>
  <li><strong>HTTP 类型</strong>：通过 <code class="language-plaintext highlighter-rouge">type: "http"</code> 指定远程 MCP 端点，适合云端服务。</li>
  <li><strong>命令类型</strong>：通过 <code class="language-plaintext highlighter-rouge">command</code> + <code class="language-plaintext highlighter-rouge">args</code> 本地启动进程，适合本地工具。</li>
</ul>

<p>注意 Continue 的 MCP 配置（<code class="language-plaintext highlighter-rouge">~/.continue/mcpServers/</code>）和 VS Code 内置的 <code class="language-plaintext highlighter-rouge">mcp.json</code> 是两套独立体系，前者给 Continue 用，后者给 Copilot 用。
两者可以同时启用，AI 工具会根据当前使用的插件选择对应的 MCP 服务器。</p>

<h3 id="53-与-continue-的分工">5.3. 与 Continue 的分工</h3>

<p>两者不是替代关系，更像是互补。下面列了一个对比表：</p>

<table>
  <thead>
    <tr>
      <th>能力</th>
      <th>Continue</th>
      <th>Copilot</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>代码补全</td>
      <td>✅ 可配置</td>
      <td>✅ 内置</td>
    </tr>
    <tr>
      <td>自定义规则</td>
      <td>✅ <code class="language-plaintext highlighter-rouge">.continue/rules</code></td>
      <td>✅ <code class="language-plaintext highlighter-rouge">.github/copilot-instructions.md</code></td>
    </tr>
    <tr>
      <td>多模型切换</td>
      <td>✅ 灵活</td>
      <td>⚠️ 有限</td>
    </tr>
    <tr>
      <td>开源</td>
      <td>✅</td>
      <td>❌</td>
    </tr>
  </tbody>
</table>

<p>我的使用习惯是：日常编码靠 Copilot 的行级补全降低输入成本，遇到复杂问题切换到 Continue 用本地模型做深度对话——
代码不会离开本机，这点对我来说很重要。</p>

<h2 id="6-实际工作流">6. 实际工作流</h2>

<p>结合两个插件和 MCP 服务器，一个典型的本地 AI 开发工作流如下：</p>

<ol>
  <li><strong>日常编码</strong>：Copilot 提供行级补全，降低输入成本。</li>
  <li><strong>复杂问题</strong>：切换到 Continue，使用本地模型进行深度对话，代码不会离开本机。</li>
  <li><strong>工具调用</strong>：通过 MCP 服务器，AI 可以直接执行本地命令、查询数据库、读取项目文档。</li>
  <li><strong>规则约束</strong>：通过项目级指令文件，确保 AI 输出符合团队规范。</li>
</ol>

<h2 id="7-需要注意">7. 需要注意</h2>

<h3 id="mcp-服务器无法启动">MCP 服务器无法启动</h3>

<p>这是最常见的问题，排查步骤：</p>

<ul>
  <li>检查 <code class="language-plaintext highlighter-rouge">mcp.json</code> 的 JSON 语法是否正确，一个多余的逗号就能让整个配置失效。</li>
  <li>查看 VS Code 日志：<code class="language-plaintext highlighter-rouge">~/Library/Application Support/Code/logs/&lt;session&gt;/mcpServer.*.log</code>，日志里通常会有明确的错误信息。</li>
  <li>确认 <code class="language-plaintext highlighter-rouge">command</code> 字段对应的可执行文件在 <code class="language-plaintext highlighter-rouge">PATH</code> 中，可以用 <code class="language-plaintext highlighter-rouge">which</code> 命令验证。</li>
</ul>

<h3 id="模型响应慢">模型响应慢</h3>

<ul>
  <li>检查显存是否充足，考虑使用量化模型（如 <code class="language-plaintext highlighter-rouge">q4_k_m</code>）。</li>
  <li>调整 <code class="language-plaintext highlighter-rouge">num_ctx</code> 参数以控制上下文窗口大小，上下文越大推理越慢。</li>
  <li>如果用的是 LM Studio，可以在界面里直接调参，比较直观。</li>
</ul>

<h3 id="continue-与-copilot-补全冲突">Continue 与 Copilot 补全冲突</h3>

<p>两者可以同时启用，但 Tab 补全只能由一个提供。在 Continue 配置中设置 <code class="language-plaintext highlighter-rouge">tabAutocompleteModel</code> 后，
Continue 会接管补全功能。如果你希望 Copilot 继续提供补全，把 Continue 里的 <code class="language-plaintext highlighter-rouge">tabAutocompleteModel</code> 去掉就行。</p>

<h2 id="a-lm-studio-加载配置实践">a. LM Studio 加载配置实践</h2>

<p>上面提到了 qwen3.6 27b 是桌面端的主力模型，下面补充一下在 LM Studio 中的具体加载配置。
这部分内容比较细，但实际体验下来，正确的参数设置对显存占用和推理速度影响很大。</p>

<p><strong>模型来源</strong>：从 Hugging Face 下载 <code class="language-plaintext highlighter-rouge">unsloth/Qwen3.6-27B-GGUF</code>，推荐 <code class="language-plaintext highlighter-rouge">UD-Q4_K_XL</code> 量化版本（次选 <code class="language-plaintext highlighter-rouge">UD-Q4_K_M</code>）。
注意不要下载带 <code class="language-plaintext highlighter-rouge">mmproj</code> 的版本——那是视觉组件，白占约 0.9 GB 显存，纯文本任务用不上。</p>

<p><strong>LM Studio 加载参数</strong>：</p>

<table>
  <thead>
    <tr>
      <th>参数</th>
      <th>推荐值</th>
      <th>说明</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Context Length</td>
      <td><code class="language-plaintext highlighter-rouge">65536</code>（手动填写）</td>
      <td>不要用默认值 <code class="language-plaintext highlighter-rouge">262144</code>，上下文窗口越大 KV Cache 占用越高</td>
    </tr>
    <tr>
      <td>GPU Offload</td>
      <td><code class="language-plaintext highlighter-rouge">64 / 64</code>（全部层上 GPU）</td>
      <td>确保模型权重完全驻留显存</td>
    </tr>
    <tr>
      <td>KV Cache Type</td>
      <td><code class="language-plaintext highlighter-rouge">Q8_0</code>（K 和 V 都设）</td>
      <td>比默认精度省约一半 KV 显存</td>
    </tr>
    <tr>
      <td>Offload KV Cache to GPU</td>
      <td>✓ 开启</td>
      <td>保持开启</td>
    </tr>
    <tr>
      <td>Flash Attention</td>
      <td>✓ 开启</td>
      <td>默认已开，无需改动</td>
    </tr>
    <tr>
      <td>Vision / mmproj</td>
      <td>✗ 不加载</td>
      <td>纯文本编码不需要视觉组件</td>
    </tr>
  </tbody>
</table>

<p><strong>预计显存占用（24 GB RTX 4090）</strong>：</p>

<ul>
  <li>模型权重：~17 GB</li>
  <li>KV Cache（Q8 @ 64K）：~2 GB</li>
  <li>CUDA overhead：~1 GB</li>
  <li><strong>合计约 20 GB</strong>，余量约 4 GB，日常编码够用 ✅</li>
</ul>

<p><strong>Agent 模式补充</strong>：Thinking mode 单次输出可达 32K–81K tokens，这是爆显存的主要原因。
建议在 <code class="language-plaintext highlighter-rouge">Inference Parameters</code> → <code class="language-plaintext highlighter-rouge">Max Tokens</code> 设为 <code class="language-plaintext highlighter-rouge">≤ 16384</code>，或者在 system prompt 里加一句 <code class="language-plaintext highlighter-rouge">Keep your thinking concise.</code> 来限制输出长度。</p>

<h2 id="b-词汇表">b. 词汇表</h2>

<p>本文涉及的部分专业术语，供快速查阅。</p>

<blockquote>
  <p><a id="glossary-token"></a>
<strong>Token</strong>：大模型处理文本的基本单位。可以粗略理解为”字”或”词碎片”——
一个英文单词通常是一个 token，一个汉字可能是一个或半个 token。
模型的上下文窗口大小、生成速度等通常以 token 数为单位。</p>
</blockquote>

<blockquote>
  <p><a id="glossary-bandwidth"></a>
<strong>内存带宽（Memory Bandwidth）</strong>：内存每秒能传输的数据量，单位通常是 GB/s。
带宽越高，模型从内存读取权重的速度越快，token 生成也就越快。
可以类比为”水管粗细”——水管越粗，单位时间流过的水越多。</p>
</blockquote>

<blockquote>
  <p><a id="glossary-vram"></a>
<strong>显存（VRAM, Video RAM）</strong>：GPU 的专用内存，类似于 CPU 的系统内存但带宽更高。
模型推理时权重数据需要加载到显存中，显存大小决定了你能跑多大的模型。
RTX 4090 的显存为 24GB，而 MacBook 的统一内存则同时充当显存和系统内存。</p>
</blockquote>

<blockquote>
  <p><a id="glossary-architecture"></a>
<strong>模型架构</strong>：本文主要涉及两种——</p>

  <ul>
    <li><strong>稠密（Dense）</strong>：模型的所有参数在每次推理时都参与计算。参数量越大，推理越慢，但通常质量更高。</li>
    <li><strong>MoE（Mixture of Experts，混合专家）</strong>：模型内部有多个”专家”子网络，每次推理只激活其中一部分。总参数量可以很大，但实际参与计算的参数较少。理论上更高效，但实际表现取决于具体实现。</li>
  </ul>
</blockquote>

<blockquote>
  <p><a id="glossary-active-params"></a>
<strong>激活参数（Active Parameters）</strong>：MoE 模型中，每次推理实际参与计算的参数量。
注意：虽然激活参数决定计算量，但总参数仍会影响内存带宽压力，因为全部权重都需要从内存中读取。</p>
</blockquote>

<blockquote>
  <p><a id="glossary-cpu-offload"></a>
<strong>CPU Offload</strong>：当模型太大、放不下 GPU 显存时，推理框架会把部分计算层”卸载”到系统内存中，由 CPU 分担计算。
由于系统内存带宽远低于显存带宽，offload 后推理速度会明显下降。在统一内存架构（如 Apple Silicon）上，这个惩罚较小。</p>
</blockquote>

<blockquote>
  <p><a id="glossary-unified-memory"></a>
<strong>统一内存（Unified Memory）</strong>：Apple Silicon（M 系列芯片）的内存架构，CPU 和 GPU 共享同一块物理内存，不存在传统 PC 上”显存”和”系统内存”的分离。
好处是模型不会受限于显存大小，代价是内存带宽低于独立显卡。</p>
</blockquote>

<blockquote>
  <p><a id="glossary-mlx"></a>
<strong>MLX 格式</strong>：<a href="https://ml.apple.com/">Apple MLX</a> 推出的机器学习框架原生模型格式，针对 Apple Silicon 的 GPU 和神经网络引擎做了硬件级优化。
同量级模型，MLX 格式在 Mac 上的推理速度通常优于 <a href="https://github.com/ggerganov/ggml/blob/master/docs/gguf.md">GGUF 格式</a>。</p>
</blockquote>

<blockquote>
  <p><a id="glossary-gguf"></a>
<strong>GGUF 格式</strong>：大模型通用的量化模型文件格式（GGML 的继任者），被 <a href="https://ollama.com/">Ollama</a>、<a href="https://lmstudio.ai/">LM Studio</a> 等主流推理工具支持。
跨平台兼容性好，但在 Apple Silicon 上的加速效果不如 MLX 格式。</p>
</blockquote>

<h2 id="参考资料">参考资料</h2>

<ul>
  <li><a href="https://marketplace.visualstudio.com/items?itemName=Continue.continue">Continue - Plugin</a></li>
  <li><a href="https://marketplace.visualstudio.com/items?itemName=GitHub.copilot">GitHub Copilot</a></li>
  <li><a href="https://qwenlm.github.io/">Qwen</a></li>
  <li><a href="https://ai.google.dev/gemma">Gemma</a></li>
  <li><a href="https://nomic.ai/resources/nomic-embed-text">Nomic Embed Text</a></li>
</ul>

<!-- refs -->]]></content><author><name>Fries_I23</name></author><category term="tools" /><category term="ai" /><category term="vscode" /><category term="mcp" /><category term="continue" /><category term="copilot" /><summary type="html"><![CDATA[从零配置 VS Code 的 Continue 和 Copilot MCP 插件，打通本地大模型与 AI 编码助手的完整工作流。]]></summary></entry><entry><title type="html">OpenWrt 23.05 配置 WireGuard VPN 服务器指南及避坑</title><link href="https://friesi23.icu/post/202606/configure-wireguard-on-openwrt-23-05" rel="alternate" type="text/html" title="OpenWrt 23.05 配置 WireGuard VPN 服务器指南及避坑" /><published>2026-06-10T00:00:00+00:00</published><updated>2026-06-10T00:00:00+00:00</updated><id>https://friesi23.icu/post/202606/configure-wireguard-on-openwrt-23-05</id><content type="html" xml:base="https://friesi23.icu/post/202606/configure-wireguard-on-openwrt-23-05"><![CDATA[<h2 id="前言">前言</h2>

<p>最近路由器上的 OpenVPN 证书到期了。</p>

<p>续签的时候突然意识到：维护一整套 PKI 证书体系对于家庭 VPN 场景来说实在有些过重——既要管 CA，又要管客户端证书吊销，过期了还得记得手动续签。
作为家庭环境，真的没必要搞得这么复杂。</p>

<p>我很懒，因此决定换成 WireGuard。理由很简单：</p>

<ul>
  <li>WireGuard 现在已经足够成熟，主流平台（Android、iOS、Windows、macOS、Linux）都有官方或社区维护的客户端</li>
  <li>连接体验比 OpenVPN 轻快不少</li>
  <li>代码量少、易于审计，而且本身就是 FOSS（GPLv2），正合我意</li>
</ul>

<p>故在这里简单记录一下在 OpenWrt 23.05 上配置 WireGuard 服务器的过程，以及踩过的几个坑。</p>

<h2 id="1-环境说明">1. 环境说明</h2>

<ul>
  <li>OpenWrt 版本：23.05</li>
  <li>WireGuard 相关包：<code class="language-plaintext highlighter-rouge">wireguard</code>、<code class="language-plaintext highlighter-rouge">wireguard-tools</code>、<code class="language-plaintext highlighter-rouge">kmod-wireguard</code></li>
  <li>路由器型号：FriendlyElec NanoPi R6S</li>
</ul>

<h2 id="2-安装-wireguard">2. 安装 WireGuard</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>opkg update
opkg <span class="nb">install </span>wireguard wireguard-tools kmod-wireguard
</code></pre></div></div>

<blockquote>
  <p><strong>踩坑提醒：谨慎安装 <code class="language-plaintext highlighter-rouge">wg-installer-server</code></strong></p>

  <p>在 OpenWrt 上部署 WireGuard 时，我曾安装 <code class="language-plaintext highlighter-rouge">wg-installer-server</code> 来简化配置，
但后续遇到了比较难排查的问题：</p>

  <ul>
    <li><code class="language-plaintext highlighter-rouge">wg0</code> 接口最初工作正常；</li>
    <li>WireGuard 路由能够正确创建；</li>
    <li>运行一段时间后，<code class="language-plaintext highlighter-rouge">wg0</code> 接口和相关路由会被自动删除；</li>
    <li>VPN 连接随之失效；</li>
    <li>问题表现得像是 WireGuard 配置错误，但实际上并非如此。</li>
  </ul>

  <p>排查后发现，问题并不在 WireGuard 本身，
而是在 <code class="language-plaintext highlighter-rouge">wg-installer-server</code> 附带的自动化管理逻辑。
该软件包除了生成配置外，还会安装额外的 hotplug 脚本，
并可能与动态路由组件（如 Babel、OLSR 等）产生交互，
从而自动修改网络配置。</p>

  <p>对于普通家庭 VPN、自建回家网络等场景，
<strong>强烈建议</strong>直接使用以下三个包即可：</p>

  <ul>
    <li><code class="language-plaintext highlighter-rouge">kmod-wireguard</code></li>
    <li><code class="language-plaintext highlighter-rouge">wireguard-tools</code></li>
    <li><code class="language-plaintext highlighter-rouge">luci-proto-wireguard</code>（如需 LuCI 界面管理）</li>
  </ul>

  <p>尽量避免引入额外的自动化管理层。
如果遇到 WireGuard 配置明明正确，
但接口或路由会被自动修改、删除的情况，
可以优先检查是否安装了 <code class="language-plaintext highlighter-rouge">wg-installer-server</code> 或类似的一键安装工具。</p>
</blockquote>

<h2 id="3-配置-wireguard-服务端">3. 配置 WireGuard 服务端</h2>

<h3 id="31-创建接口配置">3.1. 创建接口配置</h3>

<p>编辑 <code class="language-plaintext highlighter-rouge">/etc/config/network</code>，添加 WireGuard 接口配置。
以下以 LuCI 界面操作和 <code class="language-plaintext highlighter-rouge">uci</code> 命令行两种方式说明。</p>

<h4 id="通过-luci-界面配置">通过 LuCI 界面配置</h4>

<ol>
  <li>进入 <strong>网络</strong> → <strong>接口</strong> → <strong>新建接口</strong></li>
  <li>接口名称填 <code class="language-plaintext highlighter-rouge">wg0</code>，接口类型选 <strong>WireGuard VPN</strong></li>
  <li>
    <p><strong>常规设置</strong>下填写以下参数：</p>

    <p><a href="https://cdn.jsdelivr.net/gh/FriesI23/blog-image@master/img/2025-06-23-configure-wireguard-on-openwrt-23-05-pic-01.png"><img src="https://cdn.jsdelivr.net/gh/FriesI23/blog-image@master/img/2025-06-23-configure-wireguard-on-openwrt-23-05-pic-01.png" alt="接口/wg0/常规设置" height="600" /></a></p>

    <table>
      <thead>
        <tr>
          <th>参数</th>
          <th>说明</th>
          <th>示例值</th>
        </tr>
      </thead>
      <tbody>
        <tr>
          <td>私钥</td>
          <td>点击 “生成新的密钥对”</td>
          <td><code class="language-plaintext highlighter-rouge">&lt;YOUR_PRIVATE_KEY&gt;</code></td>
        </tr>
        <tr>
          <td>公钥</td>
          <td>点击 “生成新的密钥对”</td>
          <td><code class="language-plaintext highlighter-rouge">&lt;YOUR_PUBLIC_KEY&gt;</code></td>
        </tr>
        <tr>
          <td>监听端口</td>
          <td>WireGuard 服务监听的 UDP 端口</td>
          <td><code class="language-plaintext highlighter-rouge">51820</code></td>
        </tr>
        <tr>
          <td>IP 地址</td>
          <td>服务端在 WireGuard 虚拟网络中的 IP</td>
          <td><code class="language-plaintext highlighter-rouge">10.0.1.1/24</code></td>
        </tr>
        <tr>
          <td>无主机路由</td>
          <td>不添加主机路由，服务器下不要勾选</td>
          <td> </td>
        </tr>
      </tbody>
    </table>
  </li>
  <li>
    <p><strong>防火墙设置</strong>下区域选择 <code class="language-plaintext highlighter-rouge">lan</code>：</p>

    <p><a href="https://cdn.jsdelivr.net/gh/FriesI23/blog-image@master/img/2025-06-23-configure-wireguard-on-openwrt-23-05-pic-02.png"><img src="https://cdn.jsdelivr.net/gh/FriesI23/blog-image@master/img/2025-06-23-configure-wireguard-on-openwrt-23-05-pic-02.png" alt="接口/wg0/防火墙设置" height="600" /></a></p>

    <blockquote>
      <p><strong>可选</strong>：如需对 VPN 流量做更精细的防火墙控制，
可暂不将 <code class="language-plaintext highlighter-rouge">wg0</code> 加入 <code class="language-plaintext highlighter-rouge">lan</code> 区域，转而在第 4 节创建独立的防火墙区域。</p>
    </blockquote>
  </li>
</ol>

<h4 id="通过-uci-命令行配置">通过 <code class="language-plaintext highlighter-rouge">uci</code> 命令行配置</h4>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># 1. 生成 WireGuard key 并写入文件（可选保留）</span>
<span class="nb">umask </span>077
wg genkey | <span class="nb">tee</span> /etc/wireguard/wg0_private.key | wg pubkey <span class="o">&gt;</span> /etc/wireguard/wg0_public.key

<span class="c"># 2. 读取 private key 并直接写入 UCI</span>
<span class="nv">PRIVATE_KEY</span><span class="o">=</span><span class="si">$(</span><span class="nb">cat</span> /etc/wireguard/wg0_private.key<span class="si">)</span>

uci <span class="nb">set </span>network.wg0<span class="o">=</span>interface
uci <span class="nb">set </span>network.wg0.proto<span class="o">=</span><span class="s1">'wireguard'</span>
uci <span class="nb">set </span>network.wg0.listen_port<span class="o">=</span><span class="s1">'51820'</span>
uci <span class="nb">set </span>network.wg0.private_key<span class="o">=</span><span class="s2">"</span><span class="nv">$PRIVATE_KEY</span><span class="s2">"</span>
uci <span class="nb">set </span>network.wg0.addresses<span class="o">=</span><span class="s1">'10.0.1.1/24'</span>

<span class="c"># 3. 防火墙：加入 LAN 区域（关键）</span>
<span class="c">#    如需独立防火墙区域，可跳过此步，转至第 4 节配置</span>
<span class="nv">LAN_ZONE</span><span class="o">=</span><span class="si">$(</span>uci show firewall | <span class="nb">grep</span> <span class="s2">"=zone"</span> | <span class="nb">grep</span> <span class="s2">"name='lan'"</span> | <span class="nb">cut</span> <span class="nt">-d</span><span class="nb">.</span> <span class="nt">-f2</span> | <span class="nb">cut</span> <span class="nt">-d</span><span class="o">=</span> <span class="nt">-f1</span><span class="si">)</span>

uci add_list firewall.<span class="nv">$LAN_ZONE</span>.network<span class="o">=</span><span class="s1">'wg0'</span>

<span class="c"># 4. 应用配置</span>
uci commit network
uci commit firewall

/etc/init.d/network restart
/etc/init.d/firewall restart
</code></pre></div></div>

<blockquote>
  <p><strong>注意</strong>：WireGuard 虚拟接口的 IP 地址段<strong>不要</strong>与服务端所在局域网同网段，
否则会导致路由冲突，客户端无法正常访问内网资源。详见后文的<a href="#8-关键坑点">坑点说明</a>。</p>
</blockquote>

<h3 id="32-启动服务">3.2. 启动服务</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ifdown wg0 <span class="o">&amp;&amp;</span> ifup wg0
</code></pre></div></div>

<p>重启接口后，可以通过以下命令验证接口是否正常工作：</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>wg show
</code></pre></div></div>

<p>正常输出应类似如下：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>interface: wg0
  public key: &lt;SERVER_PUBLIC_KEY&gt;
  private key: (hidden)
  listening port: 51820
</code></pre></div></div>

<h2 id="4-防火墙配置可选独立区域方案">4. 防火墙配置（可选：独立区域方案）</h2>

<blockquote>
  <p><strong>默认方案已够用</strong></p>

  <p>在上文第 3 节中，我们已将 <code class="language-plaintext highlighter-rouge">wg0</code> 加入 <code class="language-plaintext highlighter-rouge">lan</code> 防火墙区域——
对于大多数家庭 VPN 场景，这已经足够：客户端可以正常访问内网和外网。</p>

  <p>本节提供一个可选的高级方案：为 WireGuard 创建独立的防火墙区域。
该方案参考了 <a href="https://upsangel.com/security/vpn/%E4%B8%80%E9%8D%B5%E9%80%A3%E5%9B%9E%E5%AE%B6%E9%87%8C%E5%85%A7%E7%B6%B2%EF%BC%9Aopenwrt%E4%B8%8A%E9%83%A8%E7%BD%B2wireguard-vpn%E6%9C%8D%E5%8B%99%E5%99%A8%E7%9A%84%E4%B8%89%E5%80%8B%E8%A6%81%E9%BB%9E/">upsangel 的文章</a>，
适合需要对 VPN 流量做更精细控制（如限制访问范围、独立日志等）的场景。
如果默认方案工作正常，可以跳过本节。</p>

  <p><strong>注意</strong>：如果采用独立区域方案，需要将第 3 节中将 <code class="language-plaintext highlighter-rouge">wg0</code> 加入 <code class="language-plaintext highlighter-rouge">lan</code> 区域的配置移除，
否则 <code class="language-plaintext highlighter-rouge">wg0</code> 会同时属于两个区域，可能导致规则冲突。</p>
</blockquote>

<hr />

<p>下面将介绍独立区域方案的配置方法。
该方案共需配置三个部分：</p>

<table>
  <thead>
    <tr>
      <th>步骤</th>
      <th>位置</th>
      <th>作用</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>1</td>
      <td>Zone Settings</td>
      <td>创建 wireguard 区域，打通 wireguard ↔ lan 双向转发</td>
    </tr>
    <tr>
      <td>2</td>
      <td>Port Forward</td>
      <td>将 WAN 口 UDP 51820 映射到路由器本机</td>
    </tr>
    <tr>
      <td>3</td>
      <td>Traffic Rule</td>
      <td>放行 WAN 入站的 UDP 51820 流量</td>
    </tr>
  </tbody>
</table>

<h3 id="41-通过-luci-界面配置">4.1. 通过 LuCI 界面配置</h3>

<ol>
  <li>
    <p>进入 <strong>网络</strong> → <strong>防火墙</strong> → <strong>区域</strong>，新建 <code class="language-plaintext highlighter-rouge">wireguard</code> 区域</p>

    <p><a href="https://cdn.jsdelivr.net/gh/FriesI23/blog-image@master/img/2025-06-23-configure-wireguard-on-openwrt-23-05-pic-03.png"><img src="https://cdn.jsdelivr.net/gh/FriesI23/blog-image@master/img/2025-06-23-configure-wireguard-on-openwrt-23-05-pic-03.png" alt="防火墙/常规" height="600" /></a></p>
    <ol>
      <li>将 <code class="language-plaintext highlighter-rouge">wg0</code> 接口分配给该区域</li>
      <li>输入 / 输出 / 转发策略均设为 <strong>接受（Accept）</strong></li>
      <li>启用 <strong>MSS 钳制（MSS Clamping / mtu_fix）</strong> 以避免分片导致的连接不稳定</li>
      <li>添加转发规则：<code class="language-plaintext highlighter-rouge">wireguard → lan</code> 以及 <code class="language-plaintext highlighter-rouge">lan → wireguard</code></li>
    </ol>
  </li>
  <li>
    <p>进入 <strong>网络</strong> → <strong>防火墙</strong> → <strong>端口转发</strong>，新建 <code class="language-plaintext highlighter-rouge">wireguard</code> 规则：</p>

    <p><a href="https://cdn.jsdelivr.net/gh/FriesI23/blog-image@master/img/2025-06-23-configure-wireguard-on-openwrt-23-05-pic-04.png"><img src="https://cdn.jsdelivr.net/gh/FriesI23/blog-image@master/img/2025-06-23-configure-wireguard-on-openwrt-23-05-pic-04.png" alt="防火墙/端口转发" height="600" /></a></p>

    <table>
      <thead>
        <tr>
          <th>字段</th>
          <th>值</th>
        </tr>
      </thead>
      <tbody>
        <tr>
          <td>名称</td>
          <td><code class="language-plaintext highlighter-rouge">wireguard</code></td>
        </tr>
        <tr>
          <td>协议</td>
          <td><code class="language-plaintext highlighter-rouge">UDP</code></td>
        </tr>
        <tr>
          <td>源区域</td>
          <td><code class="language-plaintext highlighter-rouge">wan</code></td>
        </tr>
        <tr>
          <td>外部端口</td>
          <td><code class="language-plaintext highlighter-rouge">51820</code></td>
        </tr>
        <tr>
          <td>目标区域</td>
          <td><code class="language-plaintext highlighter-rouge">lan</code></td>
        </tr>
        <tr>
          <td>内部 IP</td>
          <td><code class="language-plaintext highlighter-rouge">192.168.1.1</code>（替换为路由器实际 LAN IP）</td>
        </tr>
        <tr>
          <td>内部端口</td>
          <td><code class="language-plaintext highlighter-rouge">51820</code></td>
        </tr>
      </tbody>
    </table>
  </li>
  <li>
    <p>进入 <strong>网络</strong> → <strong>防火墙</strong> → <strong>通信规则</strong>，新建 <code class="language-plaintext highlighter-rouge">Allow-WireGuard</code> 规则：</p>

    <p><a href="https://cdn.jsdelivr.net/gh/FriesI23/blog-image@master/img/2025-06-23-configure-wireguard-on-openwrt-23-05-pic-05.png"><img src="https://cdn.jsdelivr.net/gh/FriesI23/blog-image@master/img/2025-06-23-configure-wireguard-on-openwrt-23-05-pic-05.png" alt="防火墙/通信规则" height="600" /></a></p>

    <table>
      <thead>
        <tr>
          <th>字段</th>
          <th>值</th>
        </tr>
      </thead>
      <tbody>
        <tr>
          <td>名称</td>
          <td><code class="language-plaintext highlighter-rouge">Allow-WireGuard</code></td>
        </tr>
        <tr>
          <td>协议</td>
          <td><code class="language-plaintext highlighter-rouge">UDP</code></td>
        </tr>
        <tr>
          <td>源区域</td>
          <td><code class="language-plaintext highlighter-rouge">wan</code></td>
        </tr>
        <tr>
          <td>目标区域</td>
          <td><code class="language-plaintext highlighter-rouge">设备（输入）</code></td>
        </tr>
        <tr>
          <td>目标端口</td>
          <td><code class="language-plaintext highlighter-rouge">51820</code></td>
        </tr>
        <tr>
          <td>动作</td>
          <td><code class="language-plaintext highlighter-rouge">接受</code></td>
        </tr>
      </tbody>
    </table>
  </li>
</ol>

<h3 id="42-通过-uci-命令行配置">4.2. 通过 <code class="language-plaintext highlighter-rouge">uci</code> 命令行配置</h3>

<p>没什么好说的，直接上命令：</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># 第 1 步 — 创建 WireGuard 专属防火墙区域（Zone Settings）</span>
uci add firewall zone
uci <span class="nb">set </span>firewall.@zone[-1].name<span class="o">=</span><span class="s1">'wireguard'</span>
uci add_list firewall.@zone[-1].network<span class="o">=</span><span class="s1">'wg0'</span>
uci <span class="nb">set </span>firewall.@zone[-1].input<span class="o">=</span><span class="s1">'ACCEPT'</span>
uci <span class="nb">set </span>firewall.@zone[-1].output<span class="o">=</span><span class="s1">'ACCEPT'</span>
uci <span class="nb">set </span>firewall.@zone[-1].forward<span class="o">=</span><span class="s1">'ACCEPT'</span>
uci <span class="nb">set </span>firewall.@zone[-1].mtu_fix<span class="o">=</span><span class="s1">'1'</span>

<span class="c"># 允许 wireguard ↔ lan 双向转发</span>
uci add firewall forwarding
uci <span class="nb">set </span>firewall.@forwarding[-1].src<span class="o">=</span><span class="s1">'wireguard'</span>
uci <span class="nb">set </span>firewall.@forwarding[-1].dest<span class="o">=</span><span class="s1">'lan'</span>

uci add firewall forwarding
uci <span class="nb">set </span>firewall.@forwarding[-1].src<span class="o">=</span><span class="s1">'lan'</span>
uci <span class="nb">set </span>firewall.@forwarding[-1].dest<span class="o">=</span><span class="s1">'wireguard'</span>

<span class="c"># 第 2 步 — 将 WAN UDP 51820 映射到本机（Port Forward）</span>
uci add firewall redirect
uci <span class="nb">set </span>firewall.@redirect[-1].name<span class="o">=</span><span class="s1">'wireguard'</span>
uci <span class="nb">set </span>firewall.@redirect[-1].src<span class="o">=</span><span class="s1">'wan'</span>
uci <span class="nb">set </span>firewall.@redirect[-1].proto<span class="o">=</span><span class="s1">'udp'</span>
uci <span class="nb">set </span>firewall.@redirect[-1].src_dport<span class="o">=</span><span class="s1">'51820'</span>
uci <span class="nb">set </span>firewall.@redirect[-1].dest<span class="o">=</span><span class="s1">'lan'</span>
uci <span class="nb">set </span>firewall.@redirect[-1].dest_ip<span class="o">=</span><span class="s1">'192.168.1.1'</span>   <span class="c"># 替换为路由器实际 LAN IP</span>
uci <span class="nb">set </span>firewall.@redirect[-1].dest_port<span class="o">=</span><span class="s1">'51820'</span>
uci <span class="nb">set </span>firewall.@redirect[-1].target<span class="o">=</span><span class="s1">'DNAT'</span>

<span class="c"># 第 3 步 — 放行 WAN 入站 UDP 51820（Traffic Rule）</span>
uci add firewall rule
uci <span class="nb">set </span>firewall.@rule[-1].name<span class="o">=</span><span class="s1">'Allow-WireGuard'</span>
uci <span class="nb">set </span>firewall.@rule[-1].src<span class="o">=</span><span class="s1">'wan'</span>
uci <span class="nb">set </span>firewall.@rule[-1].proto<span class="o">=</span><span class="s1">'udp'</span>
uci <span class="nb">set </span>firewall.@rule[-1].dest_port<span class="o">=</span><span class="s1">'51820'</span>
uci <span class="nb">set </span>firewall.@rule[-1].target<span class="o">=</span><span class="s1">'ACCEPT'</span>

<span class="c"># 提交并重启防火墙</span>
uci commit firewall
/etc/init.d/firewall restart
</code></pre></div></div>

<hr />

<p>至此，独立区域方案的配置就完成了。流量路径如下：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>外网客户端
  → WAN UDP 51820
  → Traffic Rule 放行
  → Port Forward 映射到本机
  → wg0 接口处理 WireGuard 握手
  → Zone Forwarding 打通 wireguard ↔ lan
  → 访问 192.168.1.x 内网设备
</code></pre></div></div>

<h2 id="5-配置-wireguard-客户端">5. 配置 WireGuard 客户端</h2>

<p>下面介绍两种为客户端生成配置的方式：通过 LuCI 界面（适合快速导出二维码）和通过 <code class="language-plaintext highlighter-rouge">uci</code> 命令行。
两种方式最终都会得到一份客户端可用的 <code class="language-plaintext highlighter-rouge">.conf</code> 配置。</p>

<h3 id="51-通过-luci-界面配置">5.1. 通过 LuCI 界面配置</h3>

<ol>
  <li>进入 <strong>网络</strong> → <strong>接口</strong> → <code class="language-plaintext highlighter-rouge">wg0</code> → <strong>对端配置</strong></li>
  <li>
    <p>点击 <strong>添加对端</strong>，填写以下参数：</p>

    <p><a href="https://cdn.jsdelivr.net/gh/FriesI23/blog-image@master/img/2025-06-23-configure-wireguard-on-openwrt-23-05-pic-06-02.png"><img src="https://cdn.jsdelivr.net/gh/FriesI23/blog-image@master/img/2025-06-23-configure-wireguard-on-openwrt-23-05-pic-06-02.png" alt="接口/wg0/编辑对端" height="600" /></a></p>

    <table>
      <thead>
        <tr>
          <th>参数</th>
          <th>说明</th>
          <th>示例值</th>
        </tr>
      </thead>
      <tbody>
        <tr>
          <td>描述</td>
          <td>客户端设备标识</td>
          <td><code class="language-plaintext highlighter-rouge">my-phone</code></td>
        </tr>
        <tr>
          <td>公钥</td>
          <td>客户端的 WireGuard 公钥；也可点击 “生成新的密钥对” 由路由器代为生成</td>
          <td><code class="language-plaintext highlighter-rouge">&lt;CLIENT_PUBLIC_KEY&gt;</code></td>
        </tr>
        <tr>
          <td>私钥</td>
          <td>若需导出完整配置给客户端使用，需填入客户端私钥；导出完成后可删除此项</td>
          <td><code class="language-plaintext highlighter-rouge">&lt;CLIENT_PRIVATE_KEY&gt;</code></td>
        </tr>
        <tr>
          <td>预共享密钥</td>
          <td>额外一层对称加密（后量子安全增强）；可点击 “生成” 自动生成</td>
          <td><code class="language-plaintext highlighter-rouge">&lt;PRESHARED_KEY&gt;</code></td>
        </tr>
        <tr>
          <td>允许的 IP</td>
          <td>客户端在 WireGuard 网络中的 IP 地址</td>
          <td><code class="language-plaintext highlighter-rouge">10.0.1.2/32</code></td>
        </tr>
        <tr>
          <td>路由允许的 IP</td>
          <td>勾选后客户端可访问路由器上的其他网段（如 <code class="language-plaintext highlighter-rouge">lan</code>）</td>
          <td>勾选</td>
        </tr>
        <tr>
          <td>持久保活</td>
          <td>保持连接活跃，穿 NAT 时有用</td>
          <td><code class="language-plaintext highlighter-rouge">25</code></td>
        </tr>
      </tbody>
    </table>

    <blockquote>
      <p><strong>提示</strong>：若客户端尚无任何配置，推荐直接在路由器上点击 “生成新的密钥对” 代为生成密钥，
这样可避免在客户端手动执行命令。</p>
    </blockquote>
  </li>
  <li>
    <p>保存后点击底部的 <strong>生成配置</strong> 按钮，系统将弹出一个二维码界面。
打开手机上的 WireGuard App，选择 “扫描二维码” 即可快速导入配置。</p>

    <p><a href="https://cdn.jsdelivr.net/gh/FriesI23/blog-image@master/img/2025-06-23-configure-wireguard-on-openwrt-23-05-pic-07.png"><img src="https://cdn.jsdelivr.net/gh/FriesI23/blog-image@master/img/2025-06-23-configure-wireguard-on-openwrt-23-05-pic-07.png" alt="接口/wg0/编辑对端/生成配置" height="600" /></a></p>

    <blockquote>
      <p><strong>注意</strong>：每个客户端设备需要单独添加一个对端参数，不能多个设备共用同一组密钥。
导入配置后，建议返回对端配置界面将 “私钥” 字段清空，以免敏感信息留在路由器配置中。</p>
    </blockquote>
  </li>
</ol>

<h3 id="52-通过-uci-命令行配置">5.2. 通过 <code class="language-plaintext highlighter-rouge">uci</code> 命令行配置</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># 1. 生成客户端密钥对</span>
<span class="nb">umask </span>077
wg genkey | <span class="nb">tee</span> /etc/wireguard/client_private.key | wg pubkey <span class="o">&gt;</span> /etc/wireguard/client_public.key

<span class="c"># 2. 读取客户端公钥</span>
<span class="nv">CLIENT_PUBLIC_KEY</span><span class="o">=</span><span class="si">$(</span><span class="nb">cat</span> /etc/wireguard/client_public.key<span class="si">)</span>

<span class="c"># 3. 在 wg0 接口下添加对端（Peer）</span>
uci add network wireguard_wg0
uci <span class="nb">set </span>network.@wireguard_wg0[-1].description<span class="o">=</span><span class="s1">'my-phone'</span>
uci <span class="nb">set </span>network.@wireguard_wg0[-1].public_key<span class="o">=</span><span class="s2">"</span><span class="nv">$CLIENT_PUBLIC_KEY</span><span class="s2">"</span>
uci <span class="nb">set </span>network.@wireguard_wg0[-1].allowed_ips<span class="o">=</span><span class="s1">'10.0.1.2/32'</span>
uci <span class="nb">set </span>network.@wireguard_wg0[-1].route_allowed_ips<span class="o">=</span><span class="s1">'1'</span>
uci <span class="nb">set </span>network.@wireguard_wg0[-1].persistent_keepalive<span class="o">=</span><span class="s1">'25'</span>

<span class="c"># 4. 生成预共享密钥（可选，但推荐）</span>
<span class="nv">PRESHARED_KEY</span><span class="o">=</span><span class="si">$(</span>wg genpsk<span class="si">)</span>
uci <span class="nb">set </span>network.@wireguard_wg0[-1].preshared_key<span class="o">=</span><span class="s2">"</span><span class="nv">$PRESHARED_KEY</span><span class="s2">"</span>

<span class="c"># 5. 应用配置</span>
uci commit network
/etc/init.d/network restart
</code></pre></div></div>

<p>若需手动生成客户端 <code class="language-plaintext highlighter-rouge">.conf</code> 文件（例如通过 SCP 传给客户端），可参考以下模板：</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">cat</span> <span class="o">&gt;</span> /tmp/wireguard-client.conf <span class="o">&lt;&lt;</span><span class="sh">'</span><span class="no">EOF</span><span class="sh">'
[Interface]
PrivateKey = &lt;CLIENT_PRIVATE_KEY&gt;
Address = 10.0.1.2/32
DNS = 10.0.1.1

[Peer]
PublicKey = &lt;SERVER_PUBLIC_KEY&gt;
PresharedKey = &lt;PRESHARED_KEY&gt;
Endpoint = &lt;YOUR_SERVER_DOMAIN_OR_IP&gt;:51820
AllowedIPs = 0.0.0.0/0
PersistentKeepalive = 25
</span><span class="no">EOF
</span></code></pre></div></div>

<p>配置项说明：</p>

<table>
  <thead>
    <tr>
      <th>字段</th>
      <th>说明</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">PrivateKey</code></td>
      <td>客户端私钥</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Address</code></td>
      <td>客户端在 WireGuard 网络中的 IP，需与服务端 Peer 配置一致（推荐 <code class="language-plaintext highlighter-rouge">/32</code>）</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">DNS</code></td>
      <td>可选，填写后可通过 VPN 解析内网域名</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">PublicKey</code></td>
      <td>服务端公钥</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">PresharedKey</code></td>
      <td>预共享密钥，额外对称加密层（可选，但推荐）</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Endpoint</code></td>
      <td>服务端公网地址和端口</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">AllowedIPs</code></td>
      <td><code class="language-plaintext highlighter-rouge">0.0.0.0/0</code> 表示所有流量走 VPN；如只需访问内网，可改为 <code class="language-plaintext highlighter-rouge">192.168.1.0/24</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">PersistentKeepalive</code></td>
      <td>保持连接活跃，穿 NAT 时有用（秒）</td>
    </tr>
  </tbody>
</table>

<h3 id="53-连接测试">5.3. 连接测试</h3>

<p>配置导入客户端后，启用连接并执行以下验证：</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># 查看路由表，确认流量经过 wg0</span>
ip route get 8.8.8.8

<span class="c"># 测试内网访问</span>
ping 192.168.1.1

<span class="c"># 测试外网访问</span>
ping 8.8.8.8
</code></pre></div></div>

<p>同时在路由器端检查 WireGuard 状态：</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># 查看接口及 Peer 信息</span>
wg show

<span class="c"># 查看接口详情</span>
ip addr show wg0

<span class="c"># 查看路由表</span>
ip route show
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">wg show</code> 的输出能直接反映客户端连接状态及传输流量，是验证配置是否生效的最快方式。</p>

<h2 id="6-客户端链接配置">6. 客户端链接配置</h2>

<p>服务端配置完成后，下面介绍在 Android 和 macOS 上配置 WireGuard 客户端的方法。</p>

<h3 id="61-android">6.1. Android</h3>

<p>Android 端推荐使用 WireGuard 官方 App（<a href="https://play.google.com/store/apps/details?id=com.wireguard.android">Google Play</a> / <a href="https://f-droid.org/en/packages/com.wireguard.android/">F-Droid</a>）。</p>

<ol>
  <li>安装 WireGuard App 后，点击右下角 <code class="language-plaintext highlighter-rouge">+</code> 号</li>
  <li>选择 <strong>从二维码扫描</strong> 或 <strong>从本地文件导入</strong>
    <ul>
      <li>若使用 LuCI 界面配置（第 5.1 节），可直接扫描路由器生成的二维码</li>
      <li>若使用命令行配置（第 5.2 节），将 <code class="language-plaintext highlighter-rouge">.conf</code> 文件传至手机后选择导入</li>
    </ul>
  </li>
  <li>导入后点击右上角开关启用连接
    <ul>
      <li><a href="https://cdn.jsdelivr.net/gh/FriesI23/blog-image@master/img/2025-06-23-configure-wireguard-on-openwrt-23-05-pic-08.jpg"><img src="https://cdn.jsdelivr.net/gh/FriesI23/blog-image@master/img/2025-06-23-configure-wireguard-on-openwrt-23-05-pic-08.jpg" alt="wireguard-android" width="300" /></a></li>
    </ul>
  </li>
</ol>

<blockquote>
  <p><strong>提示</strong>：Android 10 及以上版本支持 WireGuard 作为系统级 VPN，
连接后所有流量（包括其他 App）都会经过 VPN 隧道。
若只需部分流量走 VPN，可在 App 设置中调整 <code class="language-plaintext highlighter-rouge">AllowedIPs</code>。</p>
</blockquote>

<h3 id="62-macos">6.2. macOS</h3>

<p>macOS 端推荐使用 WireGuard 官方客户端（<a href="https://apps.apple.com/us/app/wireguard/id1441195209">App Store</a>）。</p>

<ol>
  <li>从 App Store 安装 WireGuard</li>
  <li>打开 App，点击左下角 <code class="language-plaintext highlighter-rouge">+</code> 号</li>
  <li>选择 <strong>导入隧道接口…</strong>
    <ul>
      <li>从路由器导出 <code class="language-plaintext highlighter-rouge">.conf</code> 文件，或手动粘贴配置内容</li>
    </ul>
  </li>
  <li>导入后点击右侧开关启用连接</li>
</ol>

<p><a href="https://cdn.jsdelivr.net/gh/FriesI23/blog-image@master/img/2025-06-23-configure-wireguard-on-openwrt-23-05-pic-09.png"><img src="https://cdn.jsdelivr.net/gh/FriesI23/blog-image@master/img/2025-06-23-configure-wireguard-on-openwrt-23-05-pic-09.png" alt="wireguard-macos" height="600" /></a></p>

<blockquote>
  <p><strong>注意</strong>：macOS 客户端默认会将所有流量路由至 VPN（<code class="language-plaintext highlighter-rouge">AllowedIPs = 0.0.0.0/0</code>）。
若只需访问内网资源，将 <code class="language-plaintext highlighter-rouge">AllowedIPs</code> 改为内网网段（如 <code class="language-plaintext highlighter-rouge">192.168.1.0/24</code>）即可。</p>

  <p><strong>可选替代方案</strong>：如果你不想通过 App Store 安装，
可以使用 <a href="https://github.com/mintc2/wireguard-macos-app">mintc2/wireguard-macos-app</a>，
这是一个非 App Store 打包的 WireGuard macOS 客户端，
功能与官方版本一致，可直接从 GitHub Releases 下载。</p>
</blockquote>

<h2 id="7-排错">7. 排错</h2>

<h3 id="71-常见问题排查">7.1. 常见问题排查</h3>

<table>
  <thead>
    <tr>
      <th>问题</th>
      <th>可能原因</th>
      <th>排查方向</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>客户端无法连接</td>
      <td>防火墙未放行 UDP 端口</td>
      <td>检查路由器防火墙规则，确认 WireGuard 监听端口（如 <code class="language-plaintext highlighter-rouge">51820/UDP</code>）已放行，公网环境下确认端口可达</td>
    </tr>
    <tr>
      <td>连接后无法访问内网</td>
      <td>防火墙转发规则未配置或路由错误</td>
      <td>检查 WireGuard 区域与 LAN 区域之间的转发规则；确认客户端网段已加入允许访问的路由</td>
    </tr>
    <tr>
      <td>连接后无法访问外网</td>
      <td>NAT、转发规则或 DNS 配置问题</td>
      <td>检查 <code class="language-plaintext highlighter-rouge">wireguard → wan</code> 转发规则是否存在；确认出口区域（通常为 <code class="language-plaintext highlighter-rouge">wan</code>）已启用 <code class="language-plaintext highlighter-rouge">masq='1'</code>；检查客户端 DNS 配置是否正确</td>
    </tr>
    <tr>
      <td>连接不稳定</td>
      <td>MTU 问题</td>
      <td>启用 <code class="language-plaintext highlighter-rouge">mtu_fix='1'</code>，或在客户端尝试减小 MTU（如 <code class="language-plaintext highlighter-rouge">1420</code>、<code class="language-plaintext highlighter-rouge">1380</code>）</td>
    </tr>
    <tr>
      <td>接口或路由被自动删除</td>
      <td>安装了 <code class="language-plaintext highlighter-rouge">wg-installer-server</code></td>
      <td>卸载该包，改用 OpenWrt 原生 WireGuard 配置方式</td>
    </tr>
  </tbody>
</table>

<h2 id="8-关键坑点">8. 关键坑点</h2>

<h3 id="81-peer-ip-地址不要与局域网同网段">8.1. Peer IP 地址不要与局域网同网段</h3>

<p><strong>问题描述</strong>：WireGuard 虚拟接口分配的 IP 地址如果与服务端所在局域网处于同一网段，
会导致路由冲突，客户端无法正常访问内网资源。</p>

<p><strong>示例对比</strong>：</p>

<table>
  <thead>
    <tr>
      <th>场景</th>
      <th>局域网网段</th>
      <th>WireGuard 分配</th>
      <th>结果</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>❌ 错误</td>
      <td><code class="language-plaintext highlighter-rouge">192.168.1.0/24</code></td>
      <td><code class="language-plaintext highlighter-rouge">192.168.1.100/24</code></td>
      <td>路由冲突，无法正常访问内网</td>
    </tr>
    <tr>
      <td>✅ 正确</td>
      <td><code class="language-plaintext highlighter-rouge">192.168.1.0/24</code></td>
      <td><code class="language-plaintext highlighter-rouge">10.0.1.0/24</code></td>
      <td>正常工作</td>
    </tr>
  </tbody>
</table>

<p><strong>建议</strong>：为 WireGuard 选择一个完全独立的私有网段，
如 <code class="language-plaintext highlighter-rouge">10.x.x.x</code> 或 <code class="language-plaintext highlighter-rouge">172.16.x.x</code>。</p>

<h2 id="参考资料">参考资料</h2>

<ol>
  <li><a href="https://upsangel.com/security/vpn/%E4%B8%80%E9%8D%B5%E9%80%A3%E5%9B%9E%E5%AE%B6%E9%87%8C%E5%85%A7%E7%B6%B2%EF%BC%9Aopenwrt%E4%B8%8A%E9%83%A8%E7%BD%B2wireguard-vpn%E6%9C%8D%E5%8B%99%E5%99%A8%E7%9A%84%E4%B8%89%E5%80%8B%E8%A6%81%E9%BB%9E/">一键连回家内网：OpenWrt 上部署 WireGuard VPN 服务器的三个要点</a> — upsangel</li>
  <li><a href="https://iyzm.net/openwrt/1736.html">OpenWrt 安装 WireGuard</a> — iyzm</li>
  <li><a href="https://www.cnblogs.com/mingyue5826/p/18967121">OpenWrt 搭建 WireGuard VPN</a> — mingyue5826</li>
  <li><a href="https://6xyun.cn/article/openwrt-install-wireguard">OpenWrt 安装 WireGuard</a> — 6xyun</li>
</ol>

<!-- refs -->]]></content><author><name>Fries_I23</name></author><category term="network" /><category term="openwrt" /><category term="wireguard" /><category term="vpn" /><category term="network" /><summary type="html"><![CDATA[路由器 OpenVPN 证书到期后决定换成 WireGuard，记录在 OpenWrt 23.05 上配置 WireGuard 服务器的全过程及踩过的坑。 内容包括安装、服务端与客户端配置、防火墙独立区域方案，以及常见排错。]]></summary></entry><entry><title type="html">通过环境变量为 Flutter Windows 增加 Flavor 支持</title><link href="https://friesi23.icu/post/202508/flutter-windows-variable-based-flavor" rel="alternate" type="text/html" title="通过环境变量为 Flutter Windows 增加 Flavor 支持" /><published>2025-08-31T01:00:00+00:00</published><updated>2025-08-31T01:00:00+00:00</updated><id>https://friesi23.icu/post/202508/flutter-windows-variable-based-flavor</id><content type="html" xml:base="https://friesi23.icu/post/202508/flutter-windows-variable-based-flavor"><![CDATA[<p>Flutter 已经为 Android / Darwin 增加 Flavor 支持，这有助于分离开发与发布环境构建，并可以为不同渠道的 Package 分离配置。
但 Windows 和 Linux 上的推进接近三年似乎也没有什么进展（详见 <a href="https://github.com/flutter/flutter/issues/98994">“Support flavors for Windows #98994”</a>）。</p>

<p>为了方便在 Windows 上进行开发，Flavor 的存在很有必要，所以有了这篇文章。下面将介绍通过使用<strong>环境变量</strong>来达到类似 <code class="language-plaintext highlighter-rouge">--flavor</code> 的效果。</p>

<h2 id="1-在-cmake-中引入-flavor-环境变量">1. 在 CMake 中引入 Flavor 环境变量</h2>

<p>CMake 中可以读取当前构建的环境变量，FLutter flavor 使用 <code class="language-plaintext highlighter-rouge">FLUTTER_APP_FLAVOR</code> 环境变量处理不同的 Flavor，
不过我们不能使用这个变量，会提示这些变量由 Flutter 框架管理，构建无法进行。
因此这里使用 <code class="language-plaintext highlighter-rouge">APP_FLAVOR_WIN</code> 进行区分，将以下内容按需添加到 <code class="language-plaintext highlighter-rouge">windows/CMakeLists.txt</code> 中：</p>

<div class="language-cmake highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">set</span><span class="p">(</span>APP_NAME <span class="s2">"&lt;YOUR_APP_NAME&gt;"</span><span class="p">)</span>
<span class="nb">add_definitions</span><span class="p">(</span>-DAPP_NAME=<span class="s2">"</span><span class="si">${</span><span class="nv">APP_NAME</span><span class="si">}</span><span class="s2">"</span><span class="p">)</span>

<span class="nb">if</span><span class="p">(</span>DEFINED ENV{APP_FLAVOR_WIN}<span class="p">)</span>
  <span class="c1"># CMake 中定义 FLAVOR 区分不同的 flavor</span>
  <span class="nb">set</span><span class="p">(</span>FLAVOR <span class="s2">"$ENV{APP_FLAVOR_WIN}"</span><span class="p">)</span>
  <span class="c1"># 将其引入 cpp 编译中</span>
  <span class="nb">add_definitions</span><span class="p">(</span>-DFLAVOR=<span class="s2">"</span><span class="si">${</span><span class="nv">FLAVOR</span><span class="si">}</span><span class="s2">"</span><span class="p">)</span>
  <span class="c1"># 下面区分不同的 flavor，请按照自己要求进行添加/删除/修改</span>
  <span class="nb">if</span><span class="p">(</span>FLAVOR STREQUAL <span class="s2">"f_dev"</span><span class="p">)</span>
    <span class="c1"># 定义 FLAVOR_NAME，这里作为 FLAVOR 的别名用于区分路径，后面会用到</span>
    <span class="nb">set</span><span class="p">(</span>FLAVOR_NAME <span class="s2">"dev"</span><span class="p">)</span>
    <span class="c1"># 引入 APP_TITLE_SUFFIX，用于在 cpp 中修改 windows title，后面会用到</span>
    <span class="nb">add_definitions</span><span class="p">(</span>-DAPP_TITLE_SUFFIX=<span class="s2">" - </span><span class="si">${</span><span class="nv">FLAVOR_NAME</span><span class="si">}</span><span class="s2">"</span><span class="p">)</span>
  <span class="c1"># elseif(FLAVOR STREQUAL "f_store")</span>
  <span class="c1">#   set(FLAVOR_NAME "store")</span>
  <span class="c1"># elseif(FLAVOR STREQUAL "f_generic")</span>
  <span class="c1">#   set(FLAVOR_NAME "")</span>
  <span class="nb">else</span><span class="p">()</span>
    <span class="c1"># 不支持的 flavor 就报错退出，当然也可以设置默认值不退出</span>
    <span class="nb">message</span><span class="p">(</span>FATAL_ERROR <span class="s2">"Flavor:</span><span class="si">${</span><span class="nv">FLAVOR</span><span class="si">}</span><span class="s2"> is not support, abort CMake."</span><span class="p">)</span>
  <span class="nb">endif</span><span class="p">()</span>
  <span class="nb">message</span><span class="p">(</span><span class="s2">"Building Windows with flavor: '</span><span class="si">${</span><span class="nv">FLAVOR</span><span class="si">}</span><span class="s2">'"</span><span class="p">)</span>
  <span class="c1"># 和 flutter 现有支持的 flavor 风格保持相同，将不同的 flavor 最终构建目录进行区分</span>
  <span class="c1"># 注意：flutter run 只支持默认构建目录，其他目录不被识别，后面将使用软链进行处理</span>
  <span class="nb">set</span><span class="p">(</span>CMAKE_RUNTIME_OUTPUT_DIRECTORY_DEBUG <span class="s2">"</span><span class="si">${</span><span class="nv">CMAKE_BINARY_DIR</span><span class="si">}</span><span class="s2">/runner/Debug-</span><span class="si">${</span><span class="nv">FLAVOR</span><span class="si">}</span><span class="s2">"</span><span class="p">)</span>
  <span class="nb">set</span><span class="p">(</span>CMAKE_RUNTIME_OUTPUT_DIRECTORY_RELEASE <span class="s2">"</span><span class="si">${</span><span class="nv">CMAKE_BINARY_DIR</span><span class="si">}</span><span class="s2">/runner/Release-</span><span class="si">${</span><span class="nv">FLAVOR</span><span class="si">}</span><span class="s2">"</span><span class="p">)</span>
<span class="nb">endif</span><span class="p">()</span>
</code></pre></div></div>

<h2 id="2-根据不同-flavor-处理相关内容">2. 根据不同 Flavor 处理相关内容</h2>

<p>为 windows 引入 flavor 的初衷是 <a href="https://pub.dev/packages/path_provider"><code class="language-plaintext highlighter-rouge">path_provider</code></a> 在 windows 上的路径由 <code class="language-plaintext highlighter-rouge">windows/runner/Runner.rc</code> 中的配置确定，
具体为 <code class="language-plaintext highlighter-rouge">%AppData%\Roaming\&lt;CompanyName&gt;\&lt;ProductName&gt;</code>，比如 <code class="language-plaintext highlighter-rouge">%AppData%\Roaming\io.github.friesi23\mhabit</code>。</p>

<p>而这导致默认开发时启动的应用也会读取实际使用时的用户数据，这显然不合理。一个直观的方案是在代码中进行处理，不过这显然很不优雅，
我希望能够获得在 macos 或者 linux 上一样的开发体验（macos 上拥有 flavor，linux 在分发时可以通过各种容器方案比如 appimage 或者 flatpak 对配置进行隔离）。
因此本节内容主要用于解决 <code class="language-plaintext highlighter-rouge">path_provider</code> 对应的路径问题，如果由其他需求也可以通过下面的内容举一反三。</p>

<p>首先修改 <code class="language-plaintext highlighter-rouge">windows/runner/Runner.rc</code> 文件，将其命名为 <code class="language-plaintext highlighter-rouge">windows/runner/Runner.rc.in</code>，然后内部修改如下：</p>

<pre><code class="language-rc">// ...
    BLOCK "StringFileInfo"
    BEGIN
        BLOCK "040904e4"
        BEGIN
            VALUE "CompanyName", "&lt;org.example.company&gt;" "\0"
            VALUE "FileDescription", "{FILE_DESCRIPTION}" "\0"
            VALUE "FileVersion", VERSION_AS_STRING "\0"
            VALUE "InternalName", "{INTERNAL_NAME}" "\0"
            VALUE "LegalCopyright", "Copyright (C) 2024 io.github.friesi23. All rights reserved." "\0"
            VALUE "OriginalFilename", "&lt;app&gt;.exe" "\0"
            VALUE "ProductName", "{PRODUCT_NAME}" "\0"
            VALUE "ProductVersion", VERSION_AS_STRING "\0"
        END
    END
// ...
</code></pre>

<p>将 <code class="language-plaintext highlighter-rouge">FileDescription</code>，<code class="language-plaintext highlighter-rouge">InternalName</code>，<code class="language-plaintext highlighter-rouge">ProductName</code> 使用占位符进行替换，然后修改 <code class="language-plaintext highlighter-rouge">windows/runner/CMakeLists.txt</code>，添加如下内容：</p>

<div class="language-cmake highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">set</span><span class="p">(</span>ORIGINAL_RC <span class="s2">"Runner.rc.in"</span><span class="p">)</span>
<span class="nb">set</span><span class="p">(</span>GENERATED_RC <span class="s2">"Runner.rc"</span><span class="p">)</span>
<span class="nb">file</span><span class="p">(</span>READ <span class="si">${</span><span class="nv">ORIGINAL_RC</span><span class="si">}</span> RC_CONTENT<span class="p">)</span>
<span class="c1"># FLAVOR_NAME 在 windows/CMakeLists.txt 中定义</span>
<span class="c1"># 下面代码的工作是对占位符进行替换</span>
<span class="nb">if</span><span class="p">(</span>FLAVOR_NAME<span class="p">)</span>
    <span class="nb">string</span><span class="p">(</span>REPLACE <span class="s2">"{PRODUCT_NAME}"</span> <span class="s2">"</span><span class="si">${</span><span class="nv">BINARY_NAME</span><span class="si">}</span><span class="s2">-</span><span class="si">${</span><span class="nv">FLAVOR_NAME</span><span class="si">}</span><span class="s2">"</span> RC_CONTENT <span class="s2">"</span><span class="si">${</span><span class="nv">RC_CONTENT</span><span class="si">}</span><span class="s2">"</span><span class="p">)</span>
    <span class="nb">string</span><span class="p">(</span>REPLACE <span class="s2">"{FILE_DESCRIPTION}"</span> <span class="s2">"</span><span class="si">${</span><span class="nv">BINARY_NAME</span><span class="si">}</span><span class="s2">-</span><span class="si">${</span><span class="nv">FLAVOR_NAME</span><span class="si">}</span><span class="s2">"</span> RC_CONTENT <span class="s2">"</span><span class="si">${</span><span class="nv">RC_CONTENT</span><span class="si">}</span><span class="s2">"</span><span class="p">)</span>
    <span class="nb">string</span><span class="p">(</span>REPLACE <span class="s2">"{INTERNAL_NAME}"</span> <span class="s2">"</span><span class="si">${</span><span class="nv">BINARY_NAME</span><span class="si">}</span><span class="s2">-</span><span class="si">${</span><span class="nv">FLAVOR_NAME</span><span class="si">}</span><span class="s2">"</span> RC_CONTENT <span class="s2">"</span><span class="si">${</span><span class="nv">RC_CONTENT</span><span class="si">}</span><span class="s2">"</span><span class="p">)</span>
<span class="nb">else</span><span class="p">()</span>
    <span class="nb">string</span><span class="p">(</span>REPLACE <span class="s2">"{PRODUCT_NAME}"</span> <span class="s2">"</span><span class="si">${</span><span class="nv">BINARY_NAME</span><span class="si">}</span><span class="s2">"</span> RC_CONTENT <span class="s2">"</span><span class="si">${</span><span class="nv">RC_CONTENT</span><span class="si">}</span><span class="s2">"</span><span class="p">)</span>
    <span class="nb">string</span><span class="p">(</span>REPLACE <span class="s2">"{FILE_DESCRIPTION}"</span> <span class="s2">"</span><span class="si">${</span><span class="nv">BINARY_NAME</span><span class="si">}</span><span class="s2">"</span> RC_CONTENT <span class="s2">"</span><span class="si">${</span><span class="nv">RC_CONTENT</span><span class="si">}</span><span class="s2">"</span><span class="p">)</span>
    <span class="nb">string</span><span class="p">(</span>REPLACE <span class="s2">"{INTERNAL_NAME}"</span> <span class="s2">"</span><span class="si">${</span><span class="nv">BINARY_NAME</span><span class="si">}</span><span class="s2">"</span> RC_CONTENT <span class="s2">"</span><span class="si">${</span><span class="nv">RC_CONTENT</span><span class="si">}</span><span class="s2">"</span><span class="p">)</span>
<span class="nb">endif</span><span class="p">()</span>
<span class="c1"># 写入 windows/runner/Runner.rc 保证路径与修改前一致</span>
<span class="nb">file</span><span class="p">(</span>WRITE <span class="si">${</span><span class="nv">GENERATED_RC</span><span class="si">}</span> <span class="s2">"</span><span class="si">${</span><span class="nv">RC_CONTENT</span><span class="si">}</span><span class="s2">"</span><span class="p">)</span>
</code></pre></div></div>

<p>同文件找到 <code class="language-plaintext highlighter-rouge">add_executable(${BINARY_NAME} WIN32 ...</code> 调用，并修改如下：</p>

<div class="language-cmake highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># 将 "Runner.rc" 替换为 GENERATED_RC 变量，主要为了保持代码一致，逻辑上当然也可以不改</span>
<span class="nb">add_executable</span><span class="p">(</span><span class="si">${</span><span class="nv">BINARY_NAME</span><span class="si">}</span> WIN32
  <span class="s2">"flutter_window.cpp"</span>
  <span class="s2">"main.cpp"</span>
  <span class="s2">"utils.cpp"</span>
  <span class="s2">"win32_window.cpp"</span>
  <span class="s2">"</span><span class="si">${</span><span class="nv">FLUTTER_MANAGED_DIR</span><span class="si">}</span><span class="s2">/generated_plugin_registrant.cc"</span>
  <span class="s2">"</span><span class="si">${</span><span class="nv">GENERATED_RC</span><span class="si">}</span><span class="s2">"</span>
  <span class="s2">"runner.exe.manifest"</span>
<span class="p">)</span>
</code></pre></div></div>

<p>至此已经可以让构建流程根据 <code class="language-plaintext highlighter-rouge">APP_FLAVOR_WIN</code> 变量达到 flavor 的效果，下面一节会解决 <code class="language-plaintext highlighter-rouge">flutter run</code> 的问题。</p>

<h2 id="3-兼容标准-flutter-相关命令">3. 兼容标准 flutter 相关命令</h2>

<p>在<a href="#1-在-cmake-中引入-flavor-环境变量">第一节</a>中修改 <code class="language-plaintext highlighter-rouge">windows/CMakeLists.txt</code> 中包含如下：</p>

<div class="language-cmake highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  <span class="nb">set</span><span class="p">(</span>CMAKE_RUNTIME_OUTPUT_DIRECTORY_DEBUG <span class="s2">"</span><span class="si">${</span><span class="nv">CMAKE_BINARY_DIR</span><span class="si">}</span><span class="s2">/runner/Debug-</span><span class="si">${</span><span class="nv">FLAVOR</span><span class="si">}</span><span class="s2">"</span><span class="p">)</span>
  <span class="nb">set</span><span class="p">(</span>CMAKE_RUNTIME_OUTPUT_DIRECTORY_RELEASE <span class="s2">"</span><span class="si">${</span><span class="nv">CMAKE_BINARY_DIR</span><span class="si">}</span><span class="s2">/runner/Release-</span><span class="si">${</span><span class="nv">FLAVOR</span><span class="si">}</span><span class="s2">"</span><span class="p">)</span>
</code></pre></div></div>

<p>这会将构建后的产物输出至非标准目录，而由于 flutter 本身没有实际支持 windows 下的 flavor，
因此相关执行程序需要寻找的目录是固定的，即 <code class="language-plaintext highlighter-rouge">build\windows\&lt;ARCH\runner\Release(Debug)</code>。</p>

<p>可以简单注释掉这两条命令来规避问题，但会导致本地每次构建不同 flavor 时都需要重新构建。如果希望和标准的 <code class="language-plaintext highlighter-rouge">--flavor</code> 行为保持一致，
一个可行的方案是使用软链将输出目录指向标准目录，具体操作如下：</p>

<ol>
  <li>
    <p>在 <code class="language-plaintext highlighter-rouge">windows/CMakeLists.txt</code> 下增加如下命令，作用为每次构建前删除软链，防止无 Flavor 时写入错误的目录：</p>

    <div class="language-cmake highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">function</span><span class="p">(</span>safe_remove path<span class="p">)</span>
  <span class="nb">if</span><span class="p">(</span>IS_SYMLINK <span class="s2">"</span><span class="si">${</span><span class="nv">path</span><span class="si">}</span><span class="s2">"</span><span class="p">)</span>
    <span class="nb">message</span><span class="p">(</span><span class="s2">"Removing symlink: </span><span class="si">${</span><span class="nv">path</span><span class="si">}</span><span class="s2">"</span><span class="p">)</span>
    <span class="nb">file</span><span class="p">(</span>REMOVE <span class="s2">"</span><span class="si">${</span><span class="nv">path</span><span class="si">}</span><span class="s2">"</span><span class="p">)</span>
  <span class="c1"># elseif(EXISTS "${path}")</span>
  <span class="c1">#   message("Removing directory: ${path}")</span>
  <span class="c1">#   file(REMOVE_RECURSE "${path}")</span>
  <span class="nb">endif</span><span class="p">()</span>
<span class="nb">endfunction</span><span class="p">()</span>

<span class="nb">if</span><span class="p">(</span>DEFINED FLAVOR<span class="p">)</span>
  <span class="c1"># safe_remove("${CMAKE_RUNTIME_OUTPUT_DIRECTORY_DEBUG}")</span>
  <span class="c1"># safe_remove("${CMAKE_RUNTIME_OUTPUT_DIRECTORY_RELEASE}")</span>
<span class="nb">else</span><span class="p">()</span>
  <span class="nf">safe_remove</span><span class="p">(</span><span class="s2">"</span><span class="si">${</span><span class="nv">CMAKE_BINARY_DIR</span><span class="si">}</span><span class="s2">/runner/Debug"</span><span class="p">)</span>
  <span class="nf">safe_remove</span><span class="p">(</span><span class="s2">"</span><span class="si">${</span><span class="nv">CMAKE_BINARY_DIR</span><span class="si">}</span><span class="s2">/runner/Release"</span><span class="p">)</span>
<span class="nb">endif</span><span class="p">()</span>
</code></pre></div>    </div>
  </li>
  <li>
    <p>在 <code class="language-plaintext highlighter-rouge">windows/runner/CMakeLists.txt</code> 下增加如下命令，用于在构建完成后创建软链：</p>

    <div class="language-cmake highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">if</span><span class="p">(</span>FLAVOR<span class="p">)</span>
  <span class="nb">add_custom_command</span><span class="p">(</span>TARGET <span class="si">${</span><span class="nv">BINARY_NAME</span><span class="si">}</span> POST_BUILD
    COMMAND <span class="si">${</span><span class="nv">CMAKE_COMMAND</span><span class="si">}</span> -E rm -rf
      <span class="s2">"$&lt;IF:$&lt;CONFIG:Debug&gt;,</span><span class="si">${</span><span class="nv">CMAKE_BINARY_DIR</span><span class="si">}</span><span class="s2">/runner/Debug,</span><span class="si">${</span><span class="nv">CMAKE_BINARY_DIR</span><span class="si">}</span><span class="s2">/runner/Release&gt;"</span>
    COMMAND <span class="si">${</span><span class="nv">CMAKE_COMMAND</span><span class="si">}</span> -E create_symlink
      <span class="s2">"$&lt;IF:$&lt;CONFIG:Debug&gt;,</span><span class="si">${</span><span class="nv">CMAKE_RUNTIME_OUTPUT_DIRECTORY_DEBUG</span><span class="si">}</span><span class="s2">,</span><span class="si">${</span><span class="nv">CMAKE_RUNTIME_OUTPUT_DIRECTORY_RELEASE</span><span class="si">}</span><span class="s2">&gt;"</span>
      <span class="s2">"$&lt;IF:$&lt;CONFIG:Debug&gt;,</span><span class="si">${</span><span class="nv">CMAKE_BINARY_DIR</span><span class="si">}</span><span class="s2">/runner/Debug,</span><span class="si">${</span><span class="nv">CMAKE_BINARY_DIR</span><span class="si">}</span><span class="s2">/runner/Release&gt;"</span>
  <span class="p">)</span>
<span class="nb">endif</span><span class="p">()</span>
</code></pre></div>    </div>
  </li>
</ol>

<p>至此，完成所有构建流程的修改。</p>

<h2 id="4-使用不同的-flavor-进行构建">4. 使用不同的 Flavor 进行构建</h2>

<p>上面修改完成后，我们便可以使用如下命令为不同 Flavor 进行构建：</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$</span><span class="nn">env</span><span class="p">:</span><span class="nv">APP_FLAVOR_WIN</span><span class="o">=</span><span class="err">&lt;</span><span class="n">YOUR_FLAVOR_NAME</span><span class="err">&gt;</span><span class="w">
</span><span class="n">flutter</span><span class="w"> </span><span class="nx">build</span><span class="w"> </span><span class="nx">windows</span><span class="w">
</span><span class="c"># ...</span><span class="w">
</span><span class="c"># Building Windows with flavor: &lt;YOUR_FLAVOR_NAME&gt;</span><span class="w">
</span><span class="c"># Building Windows application...</span><span class="w">
</span><span class="c"># √ Built build\windows\x64\runner\Release\&lt;app&gt;.exe</span><span class="w">
</span><span class="n">Remove-Item</span><span class="w"> </span><span class="nx">Env:\APP_FLAVOR_WIN</span><span class="w">
</span></code></pre></div></div>

<p>Flavor 构建后的目录结构如下（以 arch=x86_64 build=release 为例）：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>build\windows\x64\runner\Release-&lt;YOUR_FLAVOR_NAME&gt;
build\windows\x64\runner\Release --&gt; build\windows\x64\runner\Release-&lt;YOUR_FLAVOR_NAME&gt;
</code></pre></div></div>

<p>当然无 Flavor 的构建也是允许的：</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">flutter</span><span class="w"> </span><span class="nx">build</span><span class="w"> </span><span class="nx">windows</span><span class="w">
</span><span class="c"># ...</span><span class="w">
</span><span class="c"># Building Windows application...</span><span class="w">
</span><span class="c"># √ Built build\windows\x64\runner\Release\&lt;app&gt;.exe</span><span class="w">
</span></code></pre></div></div>

<h3 id="41-vscode-launcher-配置">4.1. vscode launcher 配置</h3>

<p>如果使用 vscode 进行开发，可以考虑在 <code class="language-plaintext highlighter-rouge">launch.json</code> 中增加如下内容，方便调试使用：</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="p">{</span><span class="w">
    </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"debug (&lt;YOUR_FLAVOR_NAME&gt;)"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"request"</span><span class="p">:</span><span class="w"> </span><span class="s2">"launch"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"dart"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"flutterMode"</span><span class="p">:</span><span class="w"> </span><span class="s2">"debug"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"args"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
      </span><span class="s2">"--flavor"</span><span class="p">,</span><span class="w">
      </span><span class="s2">"&lt;YOUR_FLAVOR_NAME&gt;"</span><span class="w">
    </span><span class="p">],</span><span class="w">
    </span><span class="nl">"env"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nl">"APP_FLAVOR_WIN"</span><span class="p">:</span><span class="w"> </span><span class="s2">"&lt;YOUR_FLAVOR_NAME&gt;"</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">},</span><span class="w">
  </span><span class="p">{</span><span class="w">
    </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"profile (&lt;YOUR_FLAVOR_NAME&gt;)"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"request"</span><span class="p">:</span><span class="w"> </span><span class="s2">"launch"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"dart"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"flutterMode"</span><span class="p">:</span><span class="w"> </span><span class="s2">"profile"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"args"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
      </span><span class="s2">"--flavor"</span><span class="p">,</span><span class="w">
      </span><span class="s2">"&lt;YOUR_FLAVOR_NAME&gt;"</span><span class="w">
    </span><span class="p">],</span><span class="w">
    </span><span class="nl">"env"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nl">"APP_FLAVOR_WIN"</span><span class="p">:</span><span class="w"> </span><span class="s2">"&lt;YOUR_FLAVOR_NAME&gt;"</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">},</span><span class="w">
  </span><span class="p">{</span><span class="w">
    </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"release (&lt;YOUR_FLAVOR_NAME&gt;)"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"request"</span><span class="p">:</span><span class="w"> </span><span class="s2">"launch"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"dart"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"flutterMode"</span><span class="p">:</span><span class="w"> </span><span class="s2">"release"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"args"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
      </span><span class="s2">"--flavor"</span><span class="p">,</span><span class="w">
      </span><span class="s2">"&lt;YOUR_FLAVOR_NAME&gt;"</span><span class="w">
    </span><span class="p">],</span><span class="w">
    </span><span class="nl">"env"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nl">"APP_FLAVOR_WIN"</span><span class="p">:</span><span class="w"> </span><span class="s2">"&lt;YOUR_FLAVOR_NAME&gt;"</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">},</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>如此便可以获得兼顾 <code class="language-plaintext highlighter-rouge">windows</code> <code class="language-plaintext highlighter-rouge">linux</code> <code class="language-plaintext highlighter-rouge">macos</code> 三端的统一运行配置。</p>

<h2 id="5-总结">5. 总结</h2>

<p>完整修改可以参考<a href="https://github.com/FriesI23/mhabit/pull/327/files">“该 PR”</a>，里面缺少了 <code class="language-plaintext highlighter-rouge">Profile</code> 的支持，
如有需要也可以很方便的进行添加。</p>

<h2 id="f1-为不同的-flavor-修改对应的-windows-title">F1. 为不同的 Flavor 修改对应的 Windows Title</h2>

<p>在<a href="#1-在-cmake-中引入-flavor-环境变量">第一节</a>中，我们使用 <code class="language-plaintext highlighter-rouge">-D</code> 传递了一些定义，可以在 windows 的入口处使用。
定位到 <code class="language-plaintext highlighter-rouge">windows/runner/main.cpp</code>，增加并修改如下：</p>

<div class="language-cpp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">#ifndef APP_NAME
#define APP_NAME L"&lt;YOUR_DEFAULT_APP_NAME&gt;"
#endif
</span>
<span class="cp">#ifdef APP_TITLE_SUFFIX
#define WINDOW_TITLE APP_NAME APP_TITLE_SUFFIX
#else
#define WINDOW_TITLE APP_NAME
#endif
</span>
<span class="c1">// ...</span>
<span class="c1">//if (!window.Create(L"&lt;YOUR_DEFAULT_APP_NAME&gt;", origin, size)) {</span>
  <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="n">window</span><span class="p">.</span><span class="n">Create</span><span class="p">(</span><span class="s">L""</span> <span class="n">WINDOW_TITLE</span><span class="p">,</span> <span class="n">origin</span><span class="p">,</span> <span class="n">size</span><span class="p">))</span> <span class="p">{</span>
    <span class="k">return</span> <span class="n">EXIT_FAILURE</span><span class="p">;</span>
  <span class="p">}</span>
  <span class="n">window</span><span class="p">.</span><span class="n">SetQuitOnClose</span><span class="p">(</span><span class="nb">true</span><span class="p">);</span>
<span class="c1">// ...</span>
</code></pre></div></div>

<!-- refs -->]]></content><author><name>Fries_I23</name></author><category term="flutter" /><category term="flutter" /><category term="flutter-build" /><category term="flutter-windows" /><category term="flutter-flavor" /><summary type="html"><![CDATA[Flutter 已经为 Android / Darwin 增加 Flavor 支持，这有助于分离开发与发布环境构建， 但 Windows 和 Linux 上的推进接近三年似乎也没有什么进展。 本文将介绍如果通过使用 env 达到近似效果。]]></summary></entry><entry><title type="html">从 OMV6 升级到 OMV7</title><link href="https://friesi23.icu/post/202507/omv-6-to-7" rel="alternate" type="text/html" title="从 OMV6 升级到 OMV7" /><published>2025-07-25T00:00:00+00:00</published><updated>2025-07-25T00:00:00+00:00</updated><id>https://friesi23.icu/post/202507/omv-6-to-7</id><content type="html" xml:base="https://friesi23.icu/post/202507/omv-6-to-7"><![CDATA[<p>近期将家中的 OpenMediaVault 由 6 升级到了 7，这里记录一下升级过程和踩到的坑。
不得不说相比当年由 4 升级到 5，OMV 的升级也算是越来越省心了。</p>

<h2 id="升级流程">升级流程</h2>

<blockquote>
  <p>参考：</p>

  <ul>
    <li><a href="https://docs.openmediavault.org/en/stable/various/apt.html">Software &amp; Update Management</a></li>
    <li><a href="https://www.openmediavault.org/?p=3615">Migrate to OMV7</a></li>
  </ul>
</blockquote>

<div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># 保证当前 omv 是最新的</span>
omv-upgrade
<span class="c"># 升级到下一版本</span>
omv-release-upgrade
</code></pre></div></div>

<p><strong>强烈建议</strong>在 TTY 而不是 SSH 下运行该命令，因此中途网络环境变更可能导致 SSH 链接断开。
最好使用外界屏幕操作，如果是无头服务器可以使用 <code class="language-plaintext highlighter-rouge">tmux</code> 或 <code class="language-plaintext highlighter-rouge">screen</code>。</p>

<p><code class="language-plaintext highlighter-rouge">omv-release-upgrade</code> 执行时间比较长，请保证计算机没有关闭（踩坑了），等待执行完毕即可。</p>

<h2 id="问题安装被意外中断">问题：安装被意外中断</h2>

<p>如果没有 UPS 或者遭到其他任何性质导致 <code class="language-plaintext highlighter-rouge">omv-release-upgrade</code> 命令执行过程中断，
大概率会导致升级不完全，此时如果已经部分升级到 omv7，则上面命令应该不存在（即无法重复执行）。</p>

<p>此时需要使用 script 尝试进行补救：</p>

<div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># copyright: ryecoaaron openmediavault</span>
<span class="c"># ref: https://forum.openmediavault.org/index.php?thread/51247-omv-6-x-to-7-x-upgrade-with-errors-update-packages-search-does-not-work-plugin-l/</span>
<span class="nb">head</span> /usr/sbin/omv-mkaptidx
<span class="nb">sudo rm</span> /usr/sbin/omv-mkaptidx
wget <span class="nt">-O</span> - https://github.com/OpenMediaVault-Plugin-Developers/installScript/raw/master/fix6to7upgrade | <span class="nb">sudo </span>bash
</code></pre></div></div>

<h2 id="问题web-ui-登录后显示-502且-ssh-无法登陆">问题：WEB UI 登录后显示 502，且 SSH 无法登陆</h2>

<p>该问题在于 omv7 使用 php8，而 omv6 使用 php7，而当前虽然已经升级到 omv7，但配置错误为 php7。</p>

<p>上述问题一般也为安装不全导致，如果使用修复脚本不起作用，可以执行一下命令：</p>

<div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>omv-salt stage run deploy
</code></pre></div></div>

<p>该命令会将新的 omv 命令应用到系统，一般执行完成后即可使系统回复正常。</p>

<h2 id="问题pve-firmware-升级错误与-firmware-intel-sound-冲突">问题：pve-firmware 升级错误，与 firmware-intel-sound 冲突</h2>

<p>升级到 omv7 后，再次执行 <code class="language-plaintext highlighter-rouge">omv-upgrade</code>，会出现如下报错：</p>

<div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># ...</span>
Preparing to unpack .../pve-firmware_3.15-2_all.deb ...
Unpacking pve-firmware <span class="o">(</span>3.15-2<span class="o">)</span> over <span class="o">(</span>3.14-3<span class="o">)</span> ...
dpkg: error processing archive /var/cache/apt/archives/pve-firmware_3.15-2_all.deb <span class="o">(</span><span class="nt">--unpack</span><span class="o">)</span>:
 trying to overwrite <span class="s1">'/lib/firmware/intel/avs/apl/dsp_basefw.bin'</span>, which is also <span class="k">in </span>package firmware-intel-sound 20241210-1~bpo12+1
Errors were encountered <span class="k">while </span>processing:
 /var/cache/apt/archives/pve-firmware_3.15-2_all.deb
</code></pre></div></div>

<p>原因是 <code class="language-plaintext highlighter-rouge">Proxmox</code> 的 <code class="language-plaintext highlighter-rouge">pve-firmware</code> 中已经包含了 <code class="language-plaintext highlighter-rouge">firmware-intel-sound</code> 包，因此产生冲突，删除该包即可：</p>

<div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">sudo </span>apt-get purge firmware-intel-sound
</code></pre></div></div>

<blockquote>
  <p>详见：<a href="https://forum.openmediavault.org/index.php?thread/56168-pve-firmware-3-15-2-update-fails/">“pve-firmware 3.15-2 Update Fails”</a></p>
</blockquote>

<h2 id="其他问题openmediavault-hddfanctrl-配置后运用不正常">其他问题：openmediavault-hddfanctrl 配置后运用不正常</h2>

<p>配置 <code class="language-plaintext highlighter-rouge">openmediavault-hddfanctrl</code> 插件并选中对应风扇和硬盘并应用后，对应的 Fan 以最小并固定的转速运行，
无法为硬盘正确降温。</p>

<p>原因是升级 <code class="language-plaintext highlighter-rouge">omv7</code> 后 <code class="language-plaintext highlighter-rouge">hdparm</code> 包不知为何丢失，重新安装即可：</p>

<div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">sudo </span>apt-get <span class="nb">install </span>hdparm
</code></pre></div></div>

<p>安装后重启 <code class="language-plaintext highlighter-rouge">openmediavault-hddfanctrl</code> 服务即可。</p>

<!-- refs -->]]></content><author><name>Fries_I23</name></author><category term="nas" /><category term="omv" /><category term="omv-release-upgrade" /><category term="omv6" /><category term="omv7" /><summary type="html"><![CDATA[近期将家中的 OpenMediaVault 由 6 升级到了 7，记录一下升级过程和踩到的坑。]]></summary></entry><entry><title type="html">为 Flutter 应用打包为 Flatpak 并以正确的姿势上架到 Flathub (下)</title><link href="https://friesi23.icu/post/202507/flutter-flatpak-and-flathub-2" rel="alternate" type="text/html" title="为 Flutter 应用打包为 Flatpak 并以正确的姿势上架到 Flathub (下)" /><published>2025-07-21T09:00:00+00:00</published><updated>2025-07-21T09:00:00+00:00</updated><id>https://friesi23.icu/post/202507/flutter-flatpak-and-flathub-2</id><content type="html" xml:base="https://friesi23.icu/post/202507/flutter-flatpak-and-flathub-2"><![CDATA[<p>本文紧接<a href="/post/202507/flutter-flatpak-and-flathub">“为 Flutter 应用打包为 Flatpak 并以正确的姿势上架到 Flathub (上)”</a>，
主要记录上架 <code class="language-plaintext highlighter-rouge">Flathub</code> 的过程。</p>

<h2 id="什么是-flathub">什么是 Flathub</h2>

<p>简单理解为在 Linux 中使用 FLatpak 技术，类似 Microsoft Store / Mac App Store / Unbuntu Snap Store 的应用商店。
可以在其<a href="https://flathub.org/">官网</a>流浪并安装应用，Flathub 也是一个 repo，也可以通过通用命令安装应用：</p>

<div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">APP_ID</span><span class="o">=</span>&lt;your-app-id&gt;
flatpak <span class="nb">install </span>flathub <span class="nv">$APP_ID</span>
flatpak run <span class="nv">$APP_ID</span>
</code></pre></div></div>

<h2 id="从哪里开始">从哪里开始</h2>

<p>由于 Flathub 相对普通的自打包有诸多额外限制，
因此<strong>强烈</strong>建议从 <a href="https://docs.flathub.org/docs/category/for-app-authors"><em>“For app authors”</em></a> 开始仔细阅读每一个章节。
本文只从 <a href="https://flathub.org/apps/io.github.friesi23.mhabit"><em>“Table Habit”</em></a> 创建于提交流程出发，记录整个过程和踩到的坑。</p>

<h2 id="1-基本要求">1. 基本要求</h2>

<p>这里是一些基本要求，这里整理了指向官方文档的链接。需要注意的是下面内容只截至到本文发表时间保证有效，如有区别请以 Flathub 官方文档为准：</p>

<table>
  <thead>
    <tr>
      <th>内容</th>
      <th>备注</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><a href="https://docs.flathub.org/docs/for-app-authors/requirements#inclusion-policy">包容性政策</a></td>
      <td>请对照查看软件是否符合上架要求</td>
    </tr>
    <tr>
      <td><a href="https://docs.flathub.org/docs/for-app-authors/requirements#application-id">App ID</a></td>
      <td>确保应用 AppID 符合上架要求，Flathub 要求使用 “<a href="https://en.wikipedia.org/wiki/Reverse_domain_name_notation">reverse-DNS</a> 命名法”</td>
    </tr>
    <tr>
      <td><a href="https://docs.flathub.org/docs/for-app-authors/requirements#license">许可</a></td>
      <td>确保使用可以被合法分发的 license，且必须在 meteinfo.xml 中进行声明</td>
    </tr>
    <tr>
      <td><a href="https://docs.flatpak.org/en/latest/sandbox-permissions.html">权限</a></td>
      <td>仔细阅读需要使用的权限，不然很容易被拒绝，详细说明后面章节会叙述；部分权限（e.g. dbus 相关）除非特殊原因<strong>肯定</strong>会被 lint CI 拒绝，请谨慎使用</td>
    </tr>
  </tbody>
</table>

<p>还有一些构建时的限制：</p>

<ul>
  <li>构建时无网络访问，即在 <code class="language-plaintext highlighter-rouge">bnuild-options</code> 中使用 <code class="language-plaintext highlighter-rouge">--share=network</code> 无效。
由于 Flutter 默认会在构建时自动下载相关依赖，因此使用 Flathub 官方教程在进行构建时几乎<strong>一定</strong>会失败。
后续会介绍使用 <a href="https://github.com/TheAppgineer/flatpak-flutter"><code class="language-plaintext highlighter-rouge">flatpak-flutter</code></a> 进行预处理，保证可以构建成功。</li>
  <li><strong>尽量</strong>从源码构建。紧接上一条，由于 Flutter 应用依赖外部下载，因此一种解决的方法便是预构建二进制文件，
再由 Flathub 直接分发。但由于不是由 Flathub 构建，可能出于导致各种各样的兼容性问题的原因，
Flathub 默认需要从源码构建（私货：个人强烈同意，从源码构建甚至是可重复构建从安全角度对于自由/开源软件是必须的）。
<a href="https://github.com/TheAppgineer/flatpak-flutter"><code class="language-plaintext highlighter-rouge">flatpak-flutter</code></a> 可以避免二进制分发，后面会赘述。</li>
</ul>

<h2 id="2-准备-repo">2. 准备 Repo</h2>

<p>使用 Github Cli：</p>

<div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>gh repo fork <span class="nt">--clone</span> flathub/flathub
<span class="nb">cd </span>flathub
git checkout <span class="nt">--track</span> origin/new-pr
git checkout <span class="nt">-b</span> my-app-submission new-pr
</code></pre></div></div>

<p>或者手动操作：</p>

<ol>
  <li><a href="https://github.com/flathub/flathub/fork">Fork Flathub/Flathub</a>，同时取消勾选 “Copy the master branch only”。</li>
  <li>
    <p>执行以下操作：</p>

    <div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git clone <span class="nt">--branch</span><span class="o">=</span>new-pr git@github.com:&lt;your-github-name&gt;/flathub.git <span class="o">&amp;&amp;</span> <span class="nb">cd </span>flathub
git checkout <span class="nt">-b</span> my-app-submission new-pr
</code></pre></div>    </div>
  </li>
</ol>

<p>确保当前分支从 <code class="language-plaintext highlighter-rouge">new-pr</code> 派生并且命名为 <code class="language-plaintext highlighter-rouge">my-app-submission</code>。</p>

<h2 id="3-需求文件与内容">3. 需求文件与内容</h2>

<p>确保上面创建的本地 repo 中包含以下文件，可选文件请参考 <a href="https://docs.flathub.org/docs/for-app-authors/requirements#required-files"><em>“Required files”</em></a>：</p>

<ol>
  <li><code class="language-plaintext highlighter-rouge">manifest.yml/yaml/json</code>：清单文件必须存在，并以 <code class="language-plaintext highlighter-rouge">&lt;app-id&gt;.yml/yaml/json</code> 命名。
如果使用 <code class="language-plaintext highlighter-rouge">flatpak-flutter</code> 该文件会由对应的 Manifest 模板自动生成。</li>
</ol>

<p>还有一些必要的文件 Flathub 几乎是<strong>强制要求</strong>在上游 repo 中维护。
不过实际查看中很多应用将这些文件放在了该 repo 中，如有需求请在提交后和维护者沟通，
这里（包含后续）默认这些文件存放在上游 repo：</p>

<ol>
  <li><code class="language-plaintext highlighter-rouge">metadata.xml</code>：需要通过 Flathub 定制的 appsstream 合法性验证，后续会详细说明。</li>
  <li>应用程序图标：要求是 svg 或者 png，后续也会再次说明；对于 GUI 应用是必选的。</li>
  <li><code class="language-plaintext highlighter-rouge">&lt;app-id&gt;.desktop</code>：该文件对 GUI 应用是必选的。</li>
</ol>

<h2 id="4-flatpak-flutter">4. flatpak-flutter</h2>

<p>在<a href="#1-基本要求">“1. 基本要求”</a>中，说明了 <code class="language-plaintext highlighter-rouge">Flutter</code> 应用默认在离线环境中几乎无法进行构建，
但 Flathub 又需要尽量避免直接提交二进制文件，
因此我们需要在清单文件中列出需要构建的 Flutter 应用中所有依赖 package 的源码构建信息。</p>

<p>上面如果手动操作的话需要查询 <code class="language-plaintext highlighter-rouge">pubspec.lock</code> 并手动或写一个脚本生成所有依赖描述，
这无疑是一项大工程，幸好有 <a href="https://github.com/TheAppgineer/flatpak-flutter"><code class="language-plaintext highlighter-rouge">flatpak-flutter</code></a> 这个现成的轮子。</p>

<p>该项目的简单原理就是将 flutter 中所有需要下载的流程转化为 Manifest 中的描述格式，
一般包含：</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">flutter-sdk-x.y.z.json</code>：编译 FLutter 需要的依赖</li>
  <li><code class="language-plaintext highlighter-rouge">pubspec-sources.json</code>：<code class="language-plaintext highlighter-rouge">pubspec.lock</code> 中列出的依赖</li>
</ul>

<p>具体说明和更多用法（比如处理外部依赖）可以直接看该项目的 README。</p>

<p>而对于一个简单的 Flutter 项目，我们需要做的是：</p>

<ol>
  <li>创建一个需要的 Manifest 模板文件，一般命名为 <code class="language-plaintext highlighter-rouge">flatpak-flutter.yml/yaml</code></li>
  <li>按 [5. Manifest] 中示例编写 Manifest 文件</li>
  <li>执行 <code class="language-plaintext highlighter-rouge">flatpak-flutter flatpak-flutter.yml</code></li>
  <li>成功后，将生成的额外清单文件加入 repo</li>
</ol>

<blockquote>
  <p>建议在 <code class="language-plaintext highlighter-rouge">.gitignore</code> 中加入如下内容，这些都是 <code class="language-plaintext highlighter-rouge">flathub / flatpak-flutter</code> 运行过程中的中间产物：</p>

  <div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>.flatpak-builder/
build/
repo/
</code></pre></div>  </div>
</blockquote>

<h2 id="5-manifest">5. Manifest</h2>

<p>与上篇文章中的 <a href="/post/202507/flutter-flatpak-and-flathub#12-manifests">“Manifest”</a> 大致相同，但有些许区别。</p>

<p>下面还是先贴上 <code class="language-plaintext highlighter-rouge">flathub/io.github.friesi23.mhabit</code> 中使用的清单文件（<code class="language-plaintext highlighter-rouge">flatpak-flutter.yaml</code>）：</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># yaml-language-server: $schema=https://raw.githubusercontent.com/flatpak/flatpak-builder/main/data/flatpak-manifest.schema.json</span>

<span class="nn">---</span>
<span class="na">app-id</span><span class="pi">:</span> <span class="s">io.github.friesi23.mhabit</span>
<span class="na">runtime</span><span class="pi">:</span> <span class="s">org.freedesktop.Sdk</span>
<span class="na">runtime-version</span><span class="pi">:</span> <span class="s2">"</span><span class="s">24.08"</span>
<span class="na">sdk</span><span class="pi">:</span> <span class="s">org.freedesktop.Sdk</span>
<span class="na">sdk-extensions</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="s">org.freedesktop.Sdk.Extension.llvm19</span>
<span class="na">command</span><span class="pi">:</span> <span class="s">mhabit</span>
<span class="na">separate-locales</span><span class="pi">:</span> <span class="no">false</span>
<span class="na">finish-args</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="s">--share=network</span>
  <span class="pi">-</span> <span class="s">--share=ipc</span>
  <span class="pi">-</span> <span class="s">--device=dri</span>
  <span class="pi">-</span> <span class="s">--socket=fallback-x11</span>
  <span class="pi">-</span> <span class="s">--socket=wayland</span>
  <span class="pi">-</span> <span class="s">--talk-name=org.freedesktop.Notifications</span>
<span class="na">modules</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="s">shared-modules/libsecret/libsecret.json</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">jsoncpp</span>
    <span class="na">buildsystem</span><span class="pi">:</span> <span class="s">meson</span>
    <span class="na">config-opts</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">--buildtype=release</span>
      <span class="pi">-</span> <span class="s">--default-library=shared</span>
    <span class="na">sources</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">type</span><span class="pi">:</span> <span class="s">archive</span>
        <span class="na">url</span><span class="pi">:</span> <span class="s">https://github.com/open-source-parsers/jsoncpp/archive/refs/tags/1.9.6.tar.gz</span>
        <span class="na">sha256</span><span class="pi">:</span> <span class="s">f93b6dd7ce796b13d02c108bc9f79812245a82e577581c4c9aabe57075c90ea2</span>
    <span class="na">cleanup</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s2">"</span><span class="s">/lib/*.a"</span>
      <span class="pi">-</span> <span class="s2">"</span><span class="s">/include"</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">mhabit</span>
    <span class="na">buildsystem</span><span class="pi">:</span> <span class="s">simple</span>
    <span class="na">build-options</span><span class="pi">:</span>
      <span class="na">arch</span><span class="pi">:</span>
        <span class="na">x86_64</span><span class="pi">:</span>
          <span class="na">env</span><span class="pi">:</span>
            <span class="na">BUNDLE_PATH</span><span class="pi">:</span> <span class="s">build/linux/x64/release/bundle</span>
        <span class="na">aarch64</span><span class="pi">:</span>
          <span class="na">env</span><span class="pi">:</span>
            <span class="na">BUNDLE_PATH</span><span class="pi">:</span> <span class="s">build/linux/arm64/release/bundle</span>
      <span class="na">append-path</span><span class="pi">:</span> <span class="s">/usr/lib/sdk/llvm19/bin:/run/build/mhabit/flutter/bin</span>
      <span class="na">prepend-ld-library-path</span><span class="pi">:</span> <span class="s">/usr/lib/sdk/llvm19/lib</span>
      <span class="na">env</span><span class="pi">:</span>
        <span class="na">PUB_CACHE</span><span class="pi">:</span> <span class="s">/run/build/mhabit/.pub-cache</span>
    <span class="na">build-commands</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">flutter build linux --release</span>
      <span class="pi">-</span> <span class="s">install -D $BUNDLE_PATH/mhabit /app/bin/mhabit</span>
      <span class="pi">-</span> <span class="s">install -Dm644 assets/logo/macos.svg /app/share/icons/hicolor/scalable/apps/io.github.friesi23.mhabit.svg</span>
      <span class="pi">-</span> <span class="s">install -Dm644 flatpak/io.github.friesi23.mhabit.metainfo.xml -t /app/share/metainfo</span>
      <span class="pi">-</span> <span class="s">install -Dm644 flatpak/io.github.friesi23.mhabit.desktop -t /app/share/applications</span>
      <span class="pi">-</span> <span class="s">cp -r $BUNDLE_PATH/lib /app/bin/lib</span>
      <span class="pi">-</span> <span class="s">cp -r $BUNDLE_PATH/data /app/bin/data</span>
    <span class="na">sources</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">type</span><span class="pi">:</span> <span class="s">git</span>
        <span class="na">url</span><span class="pi">:</span> <span class="s">https://github.com/FriesI23/mhabit.git</span>
        <span class="na">tag</span><span class="pi">:</span> <span class="s">flathub-v1.16.22+92</span>
        <span class="na">commit</span><span class="pi">:</span> <span class="s">cbedd98a6e24a6a5cc0a48bcea925928ac2087ae</span>
        <span class="na">disable-submodules</span><span class="pi">:</span> <span class="no">true</span>
      <span class="pi">-</span> <span class="na">type</span><span class="pi">:</span> <span class="s">git</span>
        <span class="na">url</span><span class="pi">:</span> <span class="s">https://github.com/flutter/flutter.git</span>
        <span class="na">tag</span><span class="pi">:</span> <span class="s">3.24.5</span>
        <span class="na">dest</span><span class="pi">:</span> <span class="s">flutter</span>
</code></pre></div></div>

<p>这里列出几个和自己构建不一样的部分：</p>

<h3 id="51-runtime">5.1. runtime</h3>

<p>截止该文章撰写时，Flathub 要求使用的 freedesktop 版本是 24.08，其他版本可能会被维护者问询或者拒绝，
如无其他必要请确保使用 Flathub 中支持（建议）使用的 runtime 包和对应版本。</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">runtime</span><span class="pi">:</span> <span class="s">org.freedesktop.Sdk</span>
<span class="na">runtime-version</span><span class="pi">:</span> <span class="s2">"</span><span class="s">24.08"</span>
</code></pre></div></div>

<h3 id="52-llvm19">5.2. llvm19</h3>

<p>编译 Flutter 需要使用 <code class="language-plaintext highlighter-rouge">clang</code>，因此需要引入 <code class="language-plaintext highlighter-rouge">llvm</code>。
请注意不同 runtime 对应的 llvm 版本，如果不清楚可以使用以下命令查看：</p>

<div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>flatpak remote-ls flathub <span class="nt">--user</span> | <span class="nb">grep </span>org.freedesktop.Sdk.Extension.llvm
</code></pre></div></div>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">sdk-extensions</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="s">org.freedesktop.Sdk.Extension.llvm19</span>
<span class="c1"># ...</span>
<span class="na">modules</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">mhabit</span>
    <span class="na">build-options</span><span class="pi">:</span>
      <span class="c1"># ...</span>
      <span class="na">append-path</span><span class="pi">:</span> <span class="s">/usr/lib/sdk/llvm19/bin:...</span>
      <span class="na">prepend-ld-library-path</span><span class="pi">:</span> <span class="s">/usr/lib/sdk/llvm19/lib</span>
    <span class="c1"># ...</span>
</code></pre></div></div>

<h3 id="53-finish-args">5.3. finish-args</h3>

<p>权限是个人在提交验证错误的并要求修改的重灾区，因为 Flathub 不光检查清单文件的合法性，
还会有很多额外的限制，具体请看：<a href="https://docs.flathub.org/docs/for-app-authors/linter">“Linter”</a>，并搜索以 “finish-args-“ 开头的语法。
个人在实践中主要遇到了一下几个问题：</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">finish-args-arbitrary-dbus-access</code>：Fluthub 禁止对任意 dbus 的访问，如有需要请申请具体的 dbus 名称，
一般不需要申请这个。</li>
  <li>finish-args-portal-talk-name：Flatpak 默认允许 <code class="language-plaintext highlighter-rouge">--talk-name=org.freedesktop.portal.&lt;...&gt;</code>，无需重复申请。
Flathub 会检查这些权限，如果存在则报错。</li>
</ul>

<p>还有一些权限需要仔细检查是否需要：</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">--talk-name=org.freedesktop.Notifications</code>：如果代码或者依赖包不支持使用 <code class="language-plaintext highlighter-rouge">org.freedesktop.portal</code> 接口弹出通知则需要该权限。</li>
  <li><code class="language-plaintext highlighter-rouge">--talk-name=org.freedesktop.&lt;...&gt;</code>：同上，如果代码使用 <code class="language-plaintext highlighter-rouge">protal</code> 接口，则都不需要申请权限，反之亦然。</li>
</ul>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">finish-args</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="s">--share=network</span>
  <span class="pi">-</span> <span class="s">--share=ipc</span>
  <span class="pi">-</span> <span class="s">--device=dri</span>
  <span class="pi">-</span> <span class="s">--socket=fallback-x11</span>
  <span class="pi">-</span> <span class="s">--socket=wayland</span>
  <span class="pi">-</span> <span class="s">--talk-name=org.freedesktop.Notifications</span>
</code></pre></div></div>

<h3 id="54-shared-modules">5.4. shared-modules</h3>

<p>一些 Flathub 中已经定义好的库存放在 <a href="https://github.com/flathub/shared-modules"><code class="language-plaintext highlighter-rouge">flathub/shared-modules</code></a> 中，
可以通过使用 submodule 引入后直接使用：</p>

<div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git submodule add https://github.com/flathub/shared-modules.git
</code></pre></div></div>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">modules</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="s">shared-modules/libsecret/libsecret.json</span>
</code></pre></div></div>

<h3 id="55-flutter">5.5. flutter</h3>

<p>通过以下方式引入 Flutter，由于 <code class="language-plaintext highlighter-rouge">flakpak-flutter</code> 脚本限制，
现在 sources 中的 App 和 Flutter 本身都需要使用 git 和 url 的方式引入，
因此如果有特殊需要可以主动修改并提 PR….</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">modules</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">mhabit</span>
    <span class="na">build-options</span><span class="pi">:</span>
      <span class="c1"># ...</span>
      <span class="na">append-path</span><span class="pi">:</span> <span class="s">...:/run/build/mhabit/flutter/bin</span>
      <span class="na">env</span><span class="pi">:</span>
        <span class="na">PUB_CACHE</span><span class="pi">:</span> <span class="s">/run/build/mhabit/.pub-cache</span>
    <span class="c1"># ...</span>
    <span class="na">sources</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">type</span><span class="pi">:</span> <span class="s">git</span>
        <span class="na">url</span><span class="pi">:</span> <span class="s">https://github.com/FriesI23/mhabit.git</span>
        <span class="na">tag</span><span class="pi">:</span> <span class="s">flathub-v1.16.22+92</span>
        <span class="na">commit</span><span class="pi">:</span> <span class="s">cbedd98a6e24a6a5cc0a48bcea925928ac2087ae</span>
        <span class="na">disable-submodules</span><span class="pi">:</span> <span class="no">true</span>
      <span class="pi">-</span> <span class="na">type</span><span class="pi">:</span> <span class="s">git</span>
        <span class="na">url</span><span class="pi">:</span> <span class="s">https://github.com/flutter/flutter.git</span>
        <span class="na">tag</span><span class="pi">:</span> <span class="s">3.24.5</span>
        <span class="na">dest</span><span class="pi">:</span> <span class="s">flutter</span>
</code></pre></div></div>

<h3 id="56-其他">5.6. 其他</h3>

<ul>
  <li>Flathub 现在要求将所有的 modules 都内联（inline），因此如无必要尽量不要手动拆分为多个文件。</li>
  <li>
    <p>请务必在文件头引入以下内容保证基本格式符合 flatpak 要求。</p>

    <div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># yaml-language-server: $schema=https://raw.githubusercontent.com/flatpak/flatpak-builder/main/data/flatpak-manifest.schema.json</span>
<span class="nn">---</span>
<span class="c1"># manifest content</span>
<span class="c1"># ...</span>
</code></pre></div>    </div>
  </li>
</ul>

<h2 id="6-metainfo-和-desktop">6. Metainfo 和 Desktop</h2>

<p>两者请在上游维护，并在 <code class="language-plaintext highlighter-rouge">Manifest</code> 中引用。以自己的应用为例：</p>

<p>在应用仓库维护如下结构：</p>

<div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>/root/flatpak
├── io.github.friesi23.mhabit.desktop
├── io.github.friesi23.mhabit.metainfo.xml
└── screenshots
    ├── en-US
    │   ├── 1.png
    │   ├── 2.png
    │   ├── 3.png
    │   ├── 4.png
    │   ├── 5.png
    │   └── 6.png
    └── zh-CN
        ├── 1.png
        ├── 2.png
        ├── 3.png
        ├── 4.png
        ├── 5.png
        └── 6.png
</code></pre></div></div>

<p>在 Manifest 的 <code class="language-plaintext highlighter-rouge">build-commands</code> 中引用：</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">modules</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">mhabit</span>
    <span class="na">build-commands</span><span class="pi">:</span>
      <span class="c1"># 编译二进制文件</span>
      <span class="pi">-</span> <span class="s">flutter build linux --release</span>
      <span class="c1"># 安装主程序</span>
      <span class="pi">-</span> <span class="s">install -D $BUNDLE_PATH/mhabit /app/bin/mhabit</span>
      <span class="c1"># 安装图标</span>
      <span class="pi">-</span> <span class="s">install -Dm644 assets/logo/macos.svg /app/share/icons/hicolor/scalable/apps/io.github.friesi23.mhabit.svg</span>
      <span class="c1"># 安装元信息</span>
      <span class="pi">-</span> <span class="s">install -Dm644 flatpak/io.github.friesi23.mhabit.metainfo.xml -t /app/share/metainfo</span>
      <span class="c1"># 安装桌面文件</span>
      <span class="pi">-</span> <span class="s">install -Dm644 flatpak/io.github.friesi23.mhabit.desktop -t /app/share/applications</span>
      <span class="c1"># 安装构建依赖</span>
      <span class="pi">-</span> <span class="s">cp -r $BUNDLE_PATH/lib /app/bin/lib</span>
      <span class="pi">-</span> <span class="s">cp -r $BUNDLE_PATH/data /app/bin/data</span>
    <span class="c1"># ...</span>
    <span class="na">sources</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">type</span><span class="pi">:</span> <span class="s">git</span>
        <span class="na">url</span><span class="pi">:</span> <span class="s">https://github.com/FriesI23/mhabit.git</span>
        <span class="na">tag</span><span class="pi">:</span> <span class="s">flathub-v1.16.22+92</span>
        <span class="na">commit</span><span class="pi">:</span> <span class="s">cbedd98a6e24a6a5cc0a48bcea925928ac2087ae</span>
        <span class="na">disable-submodules</span><span class="pi">:</span> <span class="no">true</span>
      <span class="pi">-</span> <span class="na">type</span><span class="pi">:</span> <span class="s">git</span>
        <span class="na">url</span><span class="pi">:</span> <span class="s">https://github.com/flutter/flutter.git</span>
        <span class="na">tag</span><span class="pi">:</span> <span class="s">3.24.5</span>
        <span class="na">dest</span><span class="pi">:</span> <span class="s">flutter</span>
</code></pre></div></div>

<p>另外自己在实践中有几点需要注意：</p>

<ol>
  <li>必须包含至少一个截图，并描述在 <code class="language-plaintext highlighter-rouge">screenshots/screenshot</code> 节点中。</li>
  <li><code class="language-plaintext highlighter-rouge">releases</code> 节点必须包含且至少含有一个版本，且不要随意删除版本，否则可能出发 Flathub 审查。</li>
</ol>

<p>有关元信息相关可以查看官方文档：<a href="https://docs.flathub.org/docs/for-app-authors/metainfo-guidelines#appstream-validation-errors"><em>“Appstream Validation Errors”</em></a></p>

<p>与其他应用商店类似，Falthub 也存在一个质量文档，可以查看 <a href="https://docs.flathub.org/docs/for-app-authors/metainfo-guidelines/quality-guidelines"><em>“Quality guidelines”</em></a> 并遵嘱其中标准以获得更好的产品体验。</p>

<h2 id="7-准备本地构建">7. 准备本地构建</h2>

<p>此时先保证已经执行了 <code class="language-plaintext highlighter-rouge">flatpak-flutter flatpak-flutter.yml</code>，此时本地 repo 结构应如下：</p>

<div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>├── .git
├── .gitignore
├── .gitmodules                     <span class="c"># submodule 描述信息，如果引入类似 shared-modules 则需要</span>
├── flatpak-flutter.yml <span class="c"># 模板文件</span>
├── flutter-sdk-3.24.5.json         <span class="c"># 注：这里是你自己配置的 Flutter 版本</span>
├── flutter-shared.sh.patch         <span class="c"># Patch 文件也要包含在内</span>
├── io.github.friesi23.mhabit.yml   <span class="c"># 生成的 Manifest 文件</span>
├── package_config.json
├── pubspec-sources.json            <span class="c"># 第三方 package 的 Manifest 文件</span>
└── shared-modules                  <span class="c"># 如果没有拉取 submodule，则使用 git submodule update --init --recursive 获取</span>
</code></pre></div></div>

<div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># 检查 Manifest 文件是否符合 Flathub 要求</span>
<span class="c"># 如果执行后有报错则一定要处理，否则后续提 PR 的测试构建 CI 必然会失败</span>
<span class="c"># 理想情况什么都不会输出</span>
<span class="c"># flatpak run --command=flatpak-builder-lint org.flatpak.Builder \</span>
<span class="c">#   manifest your-app-id.yml</span>
flatpak run <span class="nt">--command</span><span class="o">=</span>flatpak-builder-lint org.flatpak.Builder manifest <span class="se">\</span>
  io.github.friesi23.mhabit.yml

<span class="c"># 检查 Metainfo 文件是否符合 Flathub 要求</span>
<span class="c"># 具体同上，先保证这两个验证可以通过</span>
<span class="c"># flatpak run --command=flatpak-builder-lint org.flatpak.Builder appstream \</span>
<span class="c">#   your-app-id.yml.metainfo.xml</span>
flatpak run <span class="nt">--command</span><span class="o">=</span>flatpak-builder-lint org.flatpak.Builder appstream <span class="se">\</span>
  io.github.friesi23.mhabit.metainfo.xml

<span class="c"># 使用和 Flathub 相同的环境进行构建，如果失败可以尝试时候下面给出的命令尝试构建：</span>
<span class="c"># flatpak-builder --repo=repo --force-clean --sandbox --user \</span>
<span class="c">#   --install --install-deps-from=flathub build your-app-id.yml</span>
<span class="c"># 如果上面命令成功，则可能是环境问题</span>
<span class="c"># e.g. 不要在 vscode 的内建 shell 内执行，建议使用标准的 Terminal</span>
<span class="c">#      或者一个干净的 shell 环境，然后重复下面的 Flathub 构建命令。</span>
<span class="c">#</span>
<span class="c"># flatpak run --command=flathub-build org.flatpak.Builder your-app-id.yml</span>
flatpak run <span class="nt">--command</span><span class="o">=</span>flathub-build org.flatpak.Builder <span class="se">\</span>
  io.github.friesi23.mhabit.yml

<span class="c"># 对构建产物（repo）再进行一次检查</span>
<span class="c"># 具体同上述检查流程，保证没有报错，尽量没有警告</span>
flatpak run <span class="nt">--command</span><span class="o">=</span>flatpak-builder-lint org.flatpak.Builder repo repo
</code></pre></div></div>

<h2 id="8-准备提交">8. 准备提交</h2>

<p>这里引用原文：</p>

<blockquote>
  <p>Now open a pull request against the <code class="language-plaintext highlighter-rouge">new-pr</code> base branch on GitHub.
The title of the PR should be “Add org.example.MyAwesomeApp”.</p>
</blockquote>

<p>如图：</p>

<p><img src="https://cdn.jsdelivr.net/gh/FriesI23/blog-image@master/img/flutter-flatpak-and-flathub-2-01.jpg" alt="pr" width="600" /></p>

<p>然后就是按 Review 结构进行修改，最后等待通过即可。</p>

<h2 id="9-done">9. Done</h2>

<p>通过后，该 PR 会被自动关闭，同时新生成一个 <code class="language-plaintext highlighter-rouge">Flathub/&lt;your.app.id&gt;</code> 的仓库，并会收到一个邀请，接收后即可自行维护，
这也代表该 App 已被 Flathub 接收，稍后便能在 <code class="language-plaintext highlighter-rouge">flathub.org</code> 中查询到。</p>

<p>后需维护请参考：<a href="https://docs.flathub.org/docs/for-app-authors/maintenance"><em>“App Maintenance”</em></a>。</p>

<h2 id="总结">总结</h2>

<p>由于事先以自行查阅大量文档和其他应用的 repo 且已经成功构建出 Flatpak Single-file Bundle，
也因为 App 本省体量较小，因此提交时并没有遇到很多问题。</p>

<p>其中比较容易踩坑的就是权限问题，因为申请的权限即使用不到测试时也不会报错，而做减法永远是有风险的，
因此浪费了一定的时间排查。</p>

<p>应用上架的 PR 可以查看：<a href="https://github.com/flathub/flathub/pull/6732">“Add io.github.friesi23.mhabit #6732”</a></p>

<p>当然也建议查看其他 Open 或者 Closed 的 PR，参考学习别人的提交流程
（当然超过一两年没更新的应用就算了，可能不符合 Flathub 的最新标准），可以为极大加速应用被合并时间。</p>

<p>最后附赠一些可能有用的链接：</p>

<ul>
  <li><a href="https://docs.flathub.org/docs/for-app-authors/requirements">Flathub - Requirements</a></li>
  <li><a href="https://docs.flathub.org/docs/for-app-authors/submission">Flathub - Submission</a></li>
  <li><a href="https://docs.flathub.org/docs/for-app-authors/maintenance">Flathub - Maintenance</a></li>
  <li><a href="https://docs.flathub.org/docs/for-app-authors/linter">Flathub - Flatpak builder lint</a></li>
  <li><a href="https://docs.flathub.org/docs/for-app-authors/metainfo-guidelines">Flathub - MetaInfo guidelines</a></li>
  <li><a href="https://docs.flatpak.org/en/latest/manifests.html">Flatpak - Manifest</a></li>
  <li><a href="https://docs.flatpak.org/en/latest/sandbox-permissions.html">Flatpak - Sandbox Permissions</a></li>
  <li><a href="https://docs.flatpak.org/en/latest/module-sources.html">Flatpak - Module Sources</a></li>
  <li><a href="https://www.freedesktop.org/software/appstream/metainfocreator">AppStream - MetaInfo Creator</a></li>
  <li><a href="https://manpages.debian.org/buster/appstream/appstreamcli.1">Debian - appstreamcli</a></li>
</ul>

<!-- refs -->]]></content><author><name>Fries_I23</name></author><category term="flutter" /><category term="flutter" /><category term="flutter-linux" /><category term="flatpak" /><category term="flathub" /><summary type="html"><![CDATA[写在 Table Habit 上架到 Flathub 后，主要作为记录和供其他有需要者进行参考。 共分为两个部分，本文为下半部分：既如何以正确姿势上架到 Flathub。]]></summary></entry><entry><title type="html">为 Flutter 应用打包为 Flatpak 并以正确的姿势上架到 Flathub (上)</title><link href="https://friesi23.icu/post/202507/flutter-flatpak-and-flathub" rel="alternate" type="text/html" title="为 Flutter 应用打包为 Flatpak 并以正确的姿势上架到 Flathub (上)" /><published>2025-07-20T08:00:00+00:00</published><updated>2025-07-20T08:00:00+00:00</updated><id>https://friesi23.icu/post/202507/flutter-flatpak-and-flathub</id><content type="html" xml:base="https://friesi23.icu/post/202507/flutter-flatpak-and-flathub"><![CDATA[<p><a href="https://github.com/FriesI23/mhabit">Table Habit</a> 一直有为 Linux 进行适配，但由于自己一般不使用 Linux 桌面环境，所有一直没有动力研究打包方案。
近期看到有 issue: <a href="https://github.com/FriesI23/mhabit/issues/289"><em>“[FEATURE] Release as a Flatpak on Flathub”</em></a>，
便准备采用其中提到的的 flatpak 方案对应用进行打包.
这篇博客写在上架到 Flathub 后（你可以在<a href="https://flathub.org/apps/io.github.friesi23.mhabit">这里</a>找到我的应用信息），主要作为记录和供其他有需要者进行参考。</p>

<p>准备大体上拆分为两个部分：如何打包自己的 <code class="language-plaintext highlighter-rouge">.Flatpak</code> Bundle 与上架 Flathub 的正确姿势。本文将介绍上半部分,
如想直接查看 Flathub 上架流程请移步：
<a href="/post/202507/flutter-flatpak-and-flathub-2">为 Flutter 应用打包为 Flatpak 并以正确的姿势上架到 Flathub (下)</a>。</p>

<h2 id="什么是-flatpak">什么是 Flatpak</h2>

<p>这里不加自己的理解，直接贴上 wiki 的原文：</p>

<blockquote>
  <p>Flatpak is a utility for software deployment and package management for Linux.
It provides a sandbox environment in which users can run application software
in (partial) isolation from the rest of the system.</p>
</blockquote>

<p><a href="https://flatpak.org/about/"><em>“Flatpak History “</em></a> 也可以了解到其简要时间线。</p>

<h2 id="将自己的-flutter-应用打包为可安装的-flatpak-单文件包single-file-bundle">将自己的 Flutter 应用打包为可安装的 Flatpak 单文件包（Single-file Bundle）</h2>

<p>不论怎样，我都希望能够在应用每个版本发布后的 Release 界面中能够为使用者提供可供下载和安装的二进制文件，
因此需要在自动构建 CI 上增加对 Flatpak 单文件包的支持。当然还是以我自己的应用（Table Habit）为例：</p>

<ol>
  <li><a href="#1-创建必要文件">创建必要文件</a></li>
  <li><a href="#2-构建产物">构建产物</a></li>
  <li><a href="#3-自动化">自动化</a></li>
</ol>

<h2 id="1-创建必要文件">1. 创建必要文件</h2>

<p>对于 GUI 应用，我们在正式打包前需要创建一些必要的元信息来让 Flatpak 知道怎样进行打包，
大致包含以下几个文件：</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">&lt;manifest&gt;.yaml/yml/json</code>：清单文件，Flakpak 需要。</li>
  <li><code class="language-plaintext highlighter-rouge">&lt;metainfo&gt;.xml</code>：元信息，对应的桌面服务（e.g. Xfce）需要，Flakpak 也会读取里面相关信息。</li>
  <li><code class="language-plaintext highlighter-rouge">&lt;metainfo&gt;.desktop</code>：桌面文件，对应的桌面服务（e.g. Xfce）需要。</li>
</ul>

<h3 id="11-初始化">1.1. 初始化</h3>

<p>具体步骤请参考：<a href="https://docs.flatpak.org/en/latest/first-build.html"><em>“Building your first Flatpak”</em></a>。
简而言之，已 Debian/Xfce 为例，执行如下命令进行初始化：</p>

<div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># refs: https://flatpak.org/setup/Debian</span>
<span class="nb">sudo </span>apt <span class="nb">install </span>flatpak flatpak-builder
<span class="nb">sudo </span>apt <span class="nb">install </span>gnome-software-plugin-flatpak
flatpak remote-add <span class="nt">--if-not-exists</span> flathub https://dl.flathub.org/repo/flathub.flatpakrepo
<span class="c"># rebbot</span>
</code></pre></div></div>

<h3 id="12-manifests">1.2. Manifests</h3>

<p>首先我们需要一个 Manifest （清单）文件，Flatpak 使用 Manifest 文件进行构建，
该文件会提供有关应用程序的基本信息和构建说明。</p>

<p>Flatpak 的文档很详细，可以在 <a href="https://docs.flatpak.org/en/latest/manifests.html"><em>“Manifests”</em></a> 中找到每个 key 值的定义。
我的清单文件如下（这是一个典型的自构建 flutter 清单，请与后续提交到 Flathub 的清单进行区分），
里面部分配置会进行注释说明：</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">app-id</span><span class="pi">:</span> <span class="s">io.github.friesi23.mhabit</span>
<span class="na">runtime</span><span class="pi">:</span> <span class="s">org.freedesktop.Sdk</span>
<span class="na">runtime-version</span><span class="pi">:</span> <span class="s2">"</span><span class="s">23.08"</span>
<span class="na">sdk</span><span class="pi">:</span> <span class="s">org.freedesktop.Sdk</span>
<span class="na">command</span><span class="pi">:</span> <span class="s">mhabit</span>
<span class="na">separate-locales</span><span class="pi">:</span> <span class="no">false</span>
<span class="na">finish-args</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="s">--share=network</span>
  <span class="pi">-</span> <span class="s">--share=ipc</span>
  <span class="pi">-</span> <span class="s">--socket=fallback-x11</span>
  <span class="pi">-</span> <span class="s">--socket=wayland</span>
  <span class="pi">-</span> <span class="s">--device=dri</span>
  <span class="pi">-</span> <span class="s">--talk-name=org.freedesktop.portal.Desktop</span>
  <span class="pi">-</span> <span class="s">--talk-name=org.freedesktop.Notifications</span>
<span class="na">modules</span><span class="pi">:</span>
  <span class="c1"># Required by: flutter_secure_storage</span>
  <span class="c1"># https://github.com/flathub/shared-modules/blob/master/libsecret/libsecret.json</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">libsecret</span>
    <span class="na">buildsystem</span><span class="pi">:</span> <span class="s">meson</span>
    <span class="na">config-opts</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">-Dmanpage=false</span>
      <span class="pi">-</span> <span class="s">-Dvapi=false</span>
      <span class="pi">-</span> <span class="s">-Dgtk_doc=false</span>
      <span class="pi">-</span> <span class="s">-Dintrospection=false</span>
    <span class="na">cleanup</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">/bin</span>
      <span class="pi">-</span> <span class="s">/include</span>
      <span class="pi">-</span> <span class="s">/lib/pkgconfig</span>
      <span class="pi">-</span> <span class="s">/share/man</span>
    <span class="na">sources</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">type</span><span class="pi">:</span> <span class="s">archive</span>
        <span class="na">url</span><span class="pi">:</span> <span class="s">https://download.gnome.org/sources/libsecret/0.21/libsecret-0.21.6.tar.xz</span>
        <span class="na">sha256</span><span class="pi">:</span> <span class="s">747b8c175be108c880d3adfb9c3537ea66e520e4ad2dccf5dce58003aeeca090</span>

  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">jsoncpp</span>
    <span class="na">buildsystem</span><span class="pi">:</span> <span class="s">meson</span>
    <span class="na">config-opts</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">--buildtype=release</span>
      <span class="pi">-</span> <span class="s">--default-library=shared</span>
    <span class="na">sources</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">type</span><span class="pi">:</span> <span class="s">archive</span>
        <span class="na">url</span><span class="pi">:</span> <span class="s">https://github.com/open-source-parsers/jsoncpp/archive/refs/tags/1.9.6.tar.gz</span>
        <span class="na">sha256</span><span class="pi">:</span> <span class="s">f93b6dd7ce796b13d02c108bc9f79812245a82e577581c4c9aabe57075c90ea2</span>

  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">mhabit</span>
    <span class="na">buildsystem</span><span class="pi">:</span> <span class="s">simple</span>
    <span class="na">build-options</span><span class="pi">:</span>
      <span class="na">arch</span><span class="pi">:</span>
        <span class="na">x86_64</span><span class="pi">:</span>
          <span class="na">env</span><span class="pi">:</span>
            <span class="na">BUNDLE_PATH</span><span class="pi">:</span> <span class="s">build/linux/x64/release/bundle</span>
        <span class="na">aarch64</span><span class="pi">:</span>
          <span class="na">env</span><span class="pi">:</span>
            <span class="na">BUNDLE_PATH</span><span class="pi">:</span> <span class="s">build/linux/arm64/release/bundle</span>
    <span class="na">build-commands</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">install -D $BUNDLE_PATH/mhabit /app/bin/mhabit</span>
      <span class="pi">-</span> <span class="s">install -Dm644 assets/logo/macos.svg /app/share/icons/hicolor/scalable/apps/io.github.friesi23.mhabit.svg</span>
      <span class="pi">-</span> <span class="s">install -Dm644 configs/flatpak_builder/io.github.friesi23.mhabit.metainfo.xml -t /app/share/metainfo</span>
      <span class="pi">-</span> <span class="s">install -Dm644 configs/flatpak_builder/io.github.friesi23.mhabit.desktop -t /app/share/applications</span>
      <span class="pi">-</span> <span class="s">cp -r $BUNDLE_PATH/lib /app/bin/lib</span>
      <span class="pi">-</span> <span class="s">cp -r $BUNDLE_PATH/data /app/bin/data</span>
    <span class="na">sources</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">type</span><span class="pi">:</span> <span class="s">dir</span>
        <span class="na">path</span><span class="pi">:</span> <span class="s">../../</span>
</code></pre></div></div>

<h4 id="121-app-id">1.2.1. app-id</h4>

<p>也可以使用 id, app-id 是应用的唯一标识，应该与自己构建应用本身的 bundle 保持一致。 该 id 显示如下，
最后启动应用（执行 <code class="language-plaintext highlighter-rouge">flatpak run &lt;your-app-id&gt;</code>）时也使用的是该 id。</p>

<div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$flatpak</span> list <span class="nt">--app</span>
Name                             Application ID                    Version            Branch         Installation
Table Habit                      io.github.friesi23.mhabit         1.16.22+92         stable         user
Flatpak Builder Flatpak          org.flatpak.Builder               v0-Flathub         stable         user
...
</code></pre></div></div>

<h4 id="122-runtime-runtime-version--sdk">1.2.2. runtime, runtime-version &amp;&amp; sdk</h4>

<p>指名该应用需要的运行时环境，指名后应用安装时（<code class="language-plaintext highlighter-rouge">flatpak install ...</code>）会自动提示所有依赖的 package 依赖并安装（
如果使用 <code class="language-plaintext highlighter-rouge">-y</code> 则会自动安装）。</p>

<p>使用 <code class="language-plaintext highlighter-rouge">flatpak remote-ls flathub --runtime --user</code> 可以查看 <code class="language-plaintext highlighter-rouge">Flathub</code> 中可支持的所有 runtime。
不知道用哪个就遵循如下：</p>

<ul>
  <li>GTK: <code class="language-plaintext highlighter-rouge">org.gnome.Platform</code></li>
  <li>KDE/Qt: <code class="language-plaintext highlighter-rouge">org.kde.Platform</code></li>
  <li>跨桌面: <code class="language-plaintext highlighter-rouge">org.freedesktop.Platform</code>
    <ul>
      <li>兼容版本：<code class="language-plaintext highlighter-rouge">23.08</code></li>
      <li>* <strong>Flathub 要求的版本</strong>：<code class="language-plaintext highlighter-rouge">24.08</code></li>
    </ul>
  </li>
</ul>

<p>选择 <code class="language-plaintext highlighter-rouge">runtime</code> 后可以选择对应的 sdk，已 <code class="language-plaintext highlighter-rouge">freedesktop</code> 为例，使用 <code class="language-plaintext highlighter-rouge">org.freedesktop.Sdk</code></p>

<h4 id="123-command">1.2.3. command</h4>

<p>填写程序入口的文件名，flutter 应用的入口就是打包后位于 <code class="language-plaintext highlighter-rouge">build/linux/&lt;arch&gt;/release/bundle/&lt;pubspec.yaml#name&gt;</code></p>

<h4 id="124-finish-args">1.2.4. finish-args</h4>

<p>声明应用所需要的权限，请仔细阅读并参考 <a href="https://docs.flatpak.org/en/latest/sandbox-permissions.html"><em>“Sandbox Permissions”</em></a>。
下面只列举个人使用的部分权限：</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">--share=network</code>：如果应用需要访问网络，则加上这个权限。</li>
  <li><code class="language-plaintext highlighter-rouge">--socket=wayland</code> &amp;&amp; <code class="language-plaintext highlighter-rouge">--socket=fallback-x11</code>：对于 GUI 程序，这两个一起使用。</li>
  <li><code class="language-plaintext highlighter-rouge">--device=dri</code>：启用图形加速支持，一般都加上。</li>
  <li><code class="language-plaintext highlighter-rouge">--talk-name=org.freedesktop.portal.Desktop</code>：portal 权限，用于 freedesktop 与图形系统接口通信。
    <ul>
      <li>注意：flathub 上架的应用默认包含这个权限，所以不用添加</li>
    </ul>
  </li>
  <li><code class="language-plaintext highlighter-rouge">--talk-name=org.freedesktop.Notifications</code>：<code class="language-plaintext highlighter-rouge">flutter_local_notification</code> 需要这个权限进行通知消息弹出。
    <ul>
      <li>注意：如果代码使用 <code class="language-plaintext highlighter-rouge">freedesktop.protal</code> 进行通知通信，则不需要声明这个权限，对于其他 <code class="language-plaintext highlighter-rouge">--talk-name=...</code> 都适用。
        <ul>
          <li>e.g. <code class="language-plaintext highlighter-rouge">libsecret1</code> 支持 <code class="language-plaintext highlighter-rouge">freedesktop.protal</code> 通信，因此不需要声明 <code class="language-plaintext highlighter-rouge">--talk-name=org.freedesktop.secrets</code> 权限。</li>
        </ul>
      </li>
    </ul>
  </li>
</ul>

<blockquote>
  <p>注：对于上架 <code class="language-plaintext highlighter-rouge">Flathub</code> 的应用，权限申请有一些额外规范，请参考 <a href="https://docs.flathub.org/docs/for-app-authors/linter"><em>“Flatpak builder lint”</em></a>。</p>
</blockquote>

<h4 id="125-modules">1.2.5. modules</h4>

<p>声明需要打包的模块，所有可配置字段参阅文档：<a href="https://docs.flatpak.org/en/latest/module-sources.html"><em>“Module Sources”</em></a>。</p>

<p>一般配置本体应用打包即可，如果有额外模块，请参考文档进行源码引入并声明编译流程。一般参考配置方法的两个途径：</p>

<ol>
  <li><a href="https://github.com/flathub/shared-modules">flathub/shared-modules</a></li>
  <li>以 <code class="language-plaintext highlighter-rouge">jsoncapp</code> 为例（对应 <code class="language-plaintext highlighter-rouge">apt-get install libjsoncpp-dev</code>）：<code class="language-plaintext highlighter-rouge">https://github.com/search?q=org:flathub jsoncpp&amp;type=code</code></li>
</ol>

<p>一个需要注意的点是：所有 <code class="language-plaintext highlighter-rouge">path</code> 字段如果配置相对路径，路径起点都是 <code class="language-plaintext highlighter-rouge">Manifest</code> 文件所在位置。</p>

<blockquote>
  <p>抄一个好的配置文件是学习的开端，个人推荐：<a href="https://github.com/flathub/com.visualstudio.code/blob/master/com.visualstudio.code.yaml"><code class="language-plaintext highlighter-rouge">flathub/com.visualstudio.code</code></a></p>
</blockquote>

<h3 id="13-metainfo">1.3. Metainfo</h3>

<p><code class="language-plaintext highlighter-rouge">Appstream</code> 定义了 <code class="language-plaintext highlighter-rouge">metainfo</code>，旨在为软件在跨平台提供统一的元数据。<code class="language-plaintext highlighter-rouge">Flatpak</code> 中也建议使用 <code class="language-plaintext highlighter-rouge">metainfo.xml</code>，
可以参考 <a href="https://docs.flatpak.org/en/latest/conventions.html#metainfo-files"><em>“MetaInfo files”</em></a> 章节获得相关信息。</p>

<blockquote>
  <p>注：Flathub 对 GUI 应用的 metainfo 是强制要求，并且有额外的 lint 检查，
请注意并参考 <a href="https://docs.flathub.org/docs/for-app-authors/linter"><em>“Flatpak builder lint”</em></a>。</p>

  <p>举个栗子：<code class="language-plaintext highlighter-rouge">appstream-missing-screenshots</code> 要求商家 flathub 的应用必须提供至少一个截图。</p>
</blockquote>

<p>下面也贴出自己应用使用的元信息：</p>

<div class="language-xml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">&lt;?xml version="1.0" encoding="utf-8"?&gt;</span>
<span class="nt">&lt;component</span> <span class="na">type=</span><span class="s">"desktop"</span><span class="nt">&gt;</span>
  <span class="nt">&lt;id&gt;</span>io.github.friesi23.mhabit<span class="nt">&lt;/id&gt;</span>
  <span class="nt">&lt;name&gt;</span>Table Habit<span class="nt">&lt;/name&gt;</span>
  <span class="nt">&lt;summary&gt;</span>Micro habit tracker for personal growth<span class="nt">&lt;/summary&gt;</span>
  <span class="nt">&lt;metadata_license&gt;</span>MIT<span class="nt">&lt;/metadata_license&gt;</span>
  <span class="nt">&lt;project_license&gt;</span>Apache-2.0<span class="nt">&lt;/project_license&gt;</span>
  <span class="nt">&lt;developer_name&gt;</span>Fries_I23<span class="nt">&lt;/developer_name&gt;</span>
  <span class="nt">&lt;launchable</span> <span class="na">type=</span><span class="s">"desktop-id"</span><span class="nt">&gt;</span>io.github.friesi23.mhabit.desktop<span class="nt">&lt;/launchable&gt;</span>
  <span class="nt">&lt;provides&gt;</span>
    <span class="nt">&lt;binary&gt;</span>mhabit<span class="nt">&lt;/binary&gt;</span>
  <span class="nt">&lt;/provides&gt;</span>
  <span class="nt">&lt;screenshots&gt;</span>
    <span class="nt">&lt;screenshot</span> <span class="na">type=</span><span class="s">"default"</span><span class="nt">&gt;</span>
      <span class="nt">&lt;image&gt;</span>
        https://raw.githubusercontent.com/FriesI23/mhabit/57cd99697b852b69f6ce9e954aa7827db62ca085/flatpak/screenshots/en-US/1.png<span class="nt">&lt;/image&gt;</span>
      <span class="nt">&lt;image</span> <span class="na">xml:lang=</span><span class="s">"zh-CN"</span><span class="nt">&gt;</span>
        https://raw.githubusercontent.com/FriesI23/mhabit/57cd99697b852b69f6ce9e954aa7827db62ca085/flatpak/screenshots/zh-CN/1.png<span class="nt">&lt;/image&gt;</span>
      <span class="nt">&lt;caption&gt;</span>Home screen<span class="nt">&lt;/caption&gt;</span>
      <span class="nt">&lt;caption</span> <span class="na">xml:lang=</span><span class="s">"zh-CN"</span><span class="nt">&gt;</span>主界面<span class="nt">&lt;/caption&gt;</span>
    <span class="nt">&lt;/screenshot&gt;</span>
    <span class="nt">&lt;screenshot&gt;</span>
      <span class="nt">&lt;image&gt;</span>
        https://raw.githubusercontent.com/FriesI23/mhabit/57cd99697b852b69f6ce9e954aa7827db62ca085/flatpak/screenshots/en-US/2.png<span class="nt">&lt;/image&gt;</span>
      <span class="nt">&lt;image</span> <span class="na">xml:lang=</span><span class="s">"zh-CN"</span><span class="nt">&gt;</span>
        https://raw.githubusercontent.com/FriesI23/mhabit/57cd99697b852b69f6ce9e954aa7827db62ca085/flatpak/screenshots/zh-CN/2.png<span class="nt">&lt;/image&gt;</span>
      <span class="nt">&lt;caption&gt;</span>Dark mode<span class="nt">&lt;/caption&gt;</span>
      <span class="nt">&lt;caption</span> <span class="na">xml:lang=</span><span class="s">"zh-CN"</span><span class="nt">&gt;</span>深色模式<span class="nt">&lt;/caption&gt;</span>
    <span class="nt">&lt;/screenshot&gt;</span>
    <span class="nt">&lt;screenshot&gt;</span>
      <span class="nt">&lt;image&gt;</span>
        https://raw.githubusercontent.com/FriesI23/mhabit/57cd99697b852b69f6ce9e954aa7827db62ca085/flatpak/screenshots/en-US/3.png<span class="nt">&lt;/image&gt;</span>
      <span class="nt">&lt;image</span> <span class="na">xml:lang=</span><span class="s">"zh-CN"</span><span class="nt">&gt;</span>
        https://raw.githubusercontent.com/FriesI23/mhabit/57cd99697b852b69f6ce9e954aa7827db62ca085/flatpak/screenshots/zh-CN/3.png<span class="nt">&lt;/image&gt;</span>
      <span class="nt">&lt;caption&gt;</span>Expanded habit list<span class="nt">&lt;/caption&gt;</span>
      <span class="nt">&lt;caption</span> <span class="na">xml:lang=</span><span class="s">"zh-CN"</span><span class="nt">&gt;</span>展开习惯列表<span class="nt">&lt;/caption&gt;</span>
    <span class="nt">&lt;/screenshot&gt;</span>
    <span class="nt">&lt;screenshot&gt;</span>
      <span class="nt">&lt;image&gt;</span>
        https://raw.githubusercontent.com/FriesI23/mhabit/57cd99697b852b69f6ce9e954aa7827db62ca085/flatpak/screenshots/en-US/4.png<span class="nt">&lt;/image&gt;</span>
      <span class="nt">&lt;image</span> <span class="na">xml:lang=</span><span class="s">"zh-CN"</span><span class="nt">&gt;</span>
        https://raw.githubusercontent.com/FriesI23/mhabit/57cd99697b852b69f6ce9e954aa7827db62ca085/flatpak/screenshots/zh-CN/4.png<span class="nt">&lt;/image&gt;</span>
      <span class="nt">&lt;caption&gt;</span>Habit detail screen<span class="nt">&lt;/caption&gt;</span>
      <span class="nt">&lt;caption</span> <span class="na">xml:lang=</span><span class="s">"zh-CN"</span><span class="nt">&gt;</span>习惯详细信息界面<span class="nt">&lt;/caption&gt;</span>
    <span class="nt">&lt;/screenshot&gt;</span>
    <span class="nt">&lt;screenshot&gt;</span>
      <span class="nt">&lt;image&gt;</span>
        https://raw.githubusercontent.com/FriesI23/mhabit/57cd99697b852b69f6ce9e954aa7827db62ca085/flatpak/screenshots/en-US/5.png<span class="nt">&lt;/image&gt;</span>
      <span class="nt">&lt;image</span> <span class="na">xml:lang=</span><span class="s">"zh-CN"</span><span class="nt">&gt;</span>
        https://raw.githubusercontent.com/FriesI23/mhabit/57cd99697b852b69f6ce9e954aa7827db62ca085/flatpak/screenshots/zh-CN/5.png<span class="nt">&lt;/image&gt;</span>
      <span class="nt">&lt;caption&gt;</span>Habit heatmap<span class="nt">&lt;/caption&gt;</span>
      <span class="nt">&lt;caption</span> <span class="na">xml:lang=</span><span class="s">"zh-CN"</span><span class="nt">&gt;</span>习惯热力图<span class="nt">&lt;/caption&gt;</span>
    <span class="nt">&lt;/screenshot&gt;</span>
    <span class="nt">&lt;screenshot&gt;</span>
      <span class="nt">&lt;image&gt;</span>
        https://raw.githubusercontent.com/FriesI23/mhabit/57cd99697b852b69f6ce9e954aa7827db62ca085/flatpak/screenshots/en-US/6.png<span class="nt">&lt;/image&gt;</span>
      <span class="nt">&lt;image</span> <span class="na">xml:lang=</span><span class="s">"zh-CN"</span><span class="nt">&gt;</span>
        https://raw.githubusercontent.com/FriesI23/mhabit/57cd99697b852b69f6ce9e954aa7827db62ca085/flatpak/screenshots/zh-CN/6.png<span class="nt">&lt;/image&gt;</span>
      <span class="nt">&lt;caption&gt;</span>Batch editing screen<span class="nt">&lt;/caption&gt;</span>
      <span class="nt">&lt;caption</span> <span class="na">xml:lang=</span><span class="s">"zh-CN"</span><span class="nt">&gt;</span>批量修改界面<span class="nt">&lt;/caption&gt;</span>
    <span class="nt">&lt;/screenshot&gt;</span>
  <span class="nt">&lt;/screenshots&gt;</span>
  <span class="nt">&lt;description&gt;</span>
    <span class="nt">&lt;p&gt;</span>"Table Habit" is an app that helps you establish and track your own micro habit.
      It includes a complete set of growth curves and charts to help you build habits more
      effectively,
      and keeps your data in sync across devices (currently via WebDAV, with more options coming
      soon).<span class="nt">&lt;/p&gt;</span>
    <span class="nt">&lt;p&gt;</span>Moreover, this app is completely open source and comes with these features:<span class="nt">&lt;/p&gt;</span>
    <span class="nt">&lt;ul&gt;</span>
      <span class="nt">&lt;li&gt;</span>A scoring system to help develop your own micro habits.<span class="nt">&lt;/li&gt;</span>
      <span class="nt">&lt;li&gt;</span>Support both positive and negative habit.<span class="nt">&lt;/li&gt;</span>
      <span class="nt">&lt;li&gt;</span>An easy-to-use interface for habit check in.<span class="nt">&lt;/li&gt;</span>
      <span class="nt">&lt;li&gt;</span>Different colors used to distinguish between various habits.<span class="nt">&lt;/li&gt;</span>
      <span class="nt">&lt;li&gt;</span>Easily export and import habits using a human-readable format (JSON).<span class="nt">&lt;/li&gt;</span>
      <span class="nt">&lt;li&gt;</span>Adapt to Material3 and Dynamic Color for Android 12 and later versions.<span class="nt">&lt;/li&gt;</span>
      <span class="nt">&lt;li&gt;</span>Adaptation for landscape and large screen devices.<span class="nt">&lt;/li&gt;</span>
      <span class="nt">&lt;li&gt;</span>[Experimental] Support network sync with WebDAV.<span class="nt">&lt;/li&gt;</span>
      <span class="nt">&lt;li&gt;</span>No ADs in this app.<span class="nt">&lt;/li&gt;</span>
    <span class="nt">&lt;/ul&gt;</span>
  <span class="nt">&lt;/description&gt;</span>
  <span class="nt">&lt;url</span> <span class="na">type=</span><span class="s">"vcs-browser"</span><span class="nt">&gt;</span>https://github.com/FriesI23/mhabit<span class="nt">&lt;/url&gt;</span>
  <span class="nt">&lt;url</span> <span class="na">type=</span><span class="s">"bugtracker"</span><span class="nt">&gt;</span>https://github.com/FriesI23/mhabit/issues<span class="nt">&lt;/url&gt;</span>
  <span class="nt">&lt;url</span> <span class="na">type=</span><span class="s">"homepage"</span><span class="nt">&gt;</span>https://github.com/FriesI23/mhabit<span class="nt">&lt;/url&gt;</span>
  <span class="nt">&lt;url</span> <span class="na">type=</span><span class="s">"donation"</span><span class="nt">&gt;</span>https://github.com/FriesI23/mhabit?tab=readme-ov-file#donate<span class="nt">&lt;/url&gt;</span>
  <span class="nt">&lt;url</span> <span class="na">type=</span><span class="s">"translate"</span><span class="nt">&gt;</span>https://hosted.weblate.org/engage/mhabit/<span class="nt">&lt;/url&gt;</span>
  <span class="nt">&lt;url</span> <span class="na">type=</span><span class="s">"contribute"</span><span class="nt">&gt;</span>https://github.com/FriesI23/mhabit?tab=readme-ov-file#contributing<span class="nt">&lt;/url&gt;</span>
  <span class="c">&lt;!-- Auto generate by: https://hughsie.github.io/oars/generate.html --&gt;</span>
  <span class="nt">&lt;content_rating</span> <span class="na">type=</span><span class="s">"oars-1.1"</span><span class="nt">/&gt;</span>
  <span class="c">&lt;!-- https://specifications.freedesktop.org/menu-spec/latest/category-registry.html --&gt;</span>
  <span class="nt">&lt;categories&gt;</span>
    <span class="nt">&lt;category&gt;</span>Utility<span class="nt">&lt;/category&gt;</span>
  <span class="nt">&lt;/categories&gt;</span>
  <span class="nt">&lt;releases&gt;</span>
    <span class="nt">&lt;release</span> <span class="na">version=</span><span class="s">"1.16.22+92"</span> <span class="na">date=</span><span class="s">"2025-07-17"</span><span class="nt">&gt;</span>
      <span class="nt">&lt;url</span> <span class="na">type=</span><span class="s">"details"</span><span class="nt">&gt;</span>https://github.com/FriesI23/mhabit/releases/tag/flathub-v1.16.22+92<span class="nt">&lt;/url&gt;</span>
    <span class="nt">&lt;/release&gt;</span>
    <span class="nt">&lt;release</span> <span class="na">version=</span><span class="s">"1.16.22+91"</span> <span class="na">date=</span><span class="s">"2025-07-16"</span><span class="nt">&gt;</span>
      <span class="nt">&lt;url</span> <span class="na">type=</span><span class="s">"details"</span><span class="nt">&gt;</span>https://github.com/FriesI23/mhabit/releases/tag/v1.16.22+91<span class="nt">&lt;/url&gt;</span>
    <span class="nt">&lt;/release&gt;</span>
    <span class="nt">&lt;release</span> <span class="na">version=</span><span class="s">"1.16.21+90-pre"</span> <span class="na">date=</span><span class="s">"2025-07-15"</span><span class="nt">&gt;</span>
      <span class="nt">&lt;url</span> <span class="na">type=</span><span class="s">"details"</span><span class="nt">&gt;</span>https://github.com/FriesI23/mhabit/blob/main/CHANGELOG.md<span class="nt">&lt;/url&gt;</span>
    <span class="nt">&lt;/release&gt;</span>
  <span class="nt">&lt;/releases&gt;</span>
<span class="nt">&lt;/component&gt;</span>
</code></pre></div></div>

<p>其中有几个需要注意：</p>

<ol>
  <li><code class="language-plaintext highlighter-rouge">&lt;component type="desktop"&gt;</code> 对于 GUI 应用是必须且是 rootNode。</li>
  <li><code class="language-plaintext highlighter-rouge">id</code> 标签请与 <code class="language-plaintext highlighter-rouge">manifest</code> 中保持一致。</li>
  <li><code class="language-plaintext highlighter-rouge">&lt;launchable type="desktop-id"&gt;</code> 配置了桌面（Xfce 等）的启动信息，
如果想要应用能够从桌面菜单通过鼠标点击启动，这个配置是必须的。<code class="language-plaintext highlighter-rouge">.desktop</code> 文件下个章节会提到。</li>
  <li><code class="language-plaintext highlighter-rouge">content_rating</code> 标签建议自动生成，网址代码中已提及。</li>
  <li><code class="language-plaintext highlighter-rouge">categories</code> 请使用 Freedesktop 支持的标签名，不然 linter 检查会报错，合法标签信息网址也在代码中标注。</li>
  <li><code class="language-plaintext highlighter-rouge">releases</code> 信息需要手动维护，flatpak 在显示应用是会自动使用 <code class="language-plaintext highlighter-rouge">date</code> 最后的一个，
可以写一个脚本进行维护（e.g. <a href="https://github.com/FriesI23/mhabit/blob/main/scripts/gen_flatpak_info.sh">scripts/gen_flatpak_info.sh</a>）。
当然如果有更好的方案也可以进行分享，终归是不想也不喜欢重复造轮子。</li>
</ol>

<p>最后通过一下命令检查 <code class="language-plaintext highlighter-rouge">&lt;metainfo&gt;.xml</code> 是否合法：</p>

<div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>appstreamcli validate <span class="nt">--pedantic</span> /path/to/metainfo.xml
</code></pre></div></div>

<h3 id="14-desktop">1.4. Desktop</h3>

<p><code class="language-plaintext highlighter-rouge">Desktop</code> 文件用于向桌面环境提供有关应用的信息，可以手动创建，也可以由 <code class="language-plaintext highlighter-rouge">appstream</code> 介由 <code class="language-plaintext highlighter-rouge">metainfo.xml</code> 文件生成。</p>

<div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>appstreamcli make-desktop-file &lt;app-id&gt;.xml &lt;app-id&gt;.desktop
</code></pre></div></div>

<p>Desktop 文件比较简单，如下：</p>

<div class="language-ini highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nn">[Desktop Entry]</span>
<span class="py">Name</span><span class="p">=</span><span class="s">Table Habit</span>
<span class="py">Comment</span><span class="p">=</span><span class="s">Micro habit tracker for personal growth</span>
<span class="py">Exec</span><span class="p">=</span><span class="s">mhabit</span>
<span class="py">Icon</span><span class="p">=</span><span class="s">io.github.friesi23.mhabit</span>
<span class="py">Terminal</span><span class="p">=</span><span class="s">false</span>
<span class="py">Type</span><span class="p">=</span><span class="s">Application</span>
<span class="py">Categories</span><span class="p">=</span><span class="s">Utility</span>
<span class="py">StartupNotify</span><span class="p">=</span><span class="s">true</span>
</code></pre></div></div>

<p>相关文档也可在 <a href="https://docs.flatpak.org/en/latest/conventions.html#desktop-files"><em>“Desktop files”</em></a> 章节中找到。</p>

<h3 id="2-构建产物">2. 构建产物</h3>

<p>目标是构建 Flatpak 单文件包，因此 repo 只作为构造中间产物出现，如果需要已 repo 的形式构建并安装，
请参考：<a href="https://docs.flatpak.org/en/latest/publishing.html"><em>“Flatpak/Publishing”</em></a>.</p>

<div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># 安装依赖</span>
apt-get <span class="nb">install</span> <span class="nt">-y</span> flatpak flatpak-builder
flatpak remote-add <span class="nt">--if-not-exists</span> flathub https://flathub.org/repo/flathub.flatpakrepo
<span class="c"># 根据 &lt;manifest&gt;.yaml 内的 runtime 获取对应的构建依赖，这里以 freedesktop-23.08 举例</span>
flatpak <span class="nb">install</span> <span class="nt">-y</span> org.freedesktop.Sdk/x86_64/23.08
flatpak <span class="nb">install</span> <span class="nt">-y</span> org.freedesktop.Platform/x86_64/23.08
flatpak <span class="nb">install</span> <span class="nt">-y</span> flathub org.freedesktop.appstream-glib
<span class="c"># 构建 linux 产物</span>
flutter build linux <span class="nt">--release</span>
<span class="c"># 构建 flatpak repo</span>
flatpak-builder <span class="nt">--force-clean</span> build-dir <span class="se">\</span>
    <span class="nt">--repo</span><span class="o">=</span>repo-dir <span class="nt">--default-branch</span><span class="o">=</span>main <span class="se">\</span>
    &lt;path/to/manifest.yaml&gt;
<span class="c"># 构建 flatpak bundle</span>
flatpak build-bundle repo-dir &lt;custom-name&gt;.flatpak &lt;app-id&gt; main
</code></pre></div></div>

<h3 id="3-自动化">3. 自动化</h3>

<p>将上面的步骤集成到 <code class="language-plaintext highlighter-rouge">Github Action</code> 中，每次推送对应 tag 都能自动进行 Release 版本构建并发布。</p>

<p>由于第三方 Workflow <code class="language-plaintext highlighter-rouge">flatpak/flatpak-github-actions/flatpak-builder@v6</code> 需要使用自定义容器，
因此将构造 Linux 产物和构造 Flatpak 产物分为两个 job 执行，并使用 artifact 传递构造中间产物。</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">name</span><span class="pi">:</span> <span class="s">Release</span>
<span class="na">on</span><span class="pi">:</span>
  <span class="na">push</span><span class="pi">:</span>
    <span class="na">tags</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s1">'</span><span class="s">v[0-9]+.[0-9]+.[0-9]+\+[0-9]+'</span>

<span class="na">jobs</span><span class="pi">:</span>
  <span class="c1"># Step1: 编译 Linux 二进制文件</span>
  <span class="na">build-linux</span><span class="pi">:</span>
    <span class="na">name</span><span class="pi">:</span> <span class="s2">"</span><span class="s">Build</span><span class="nv"> </span><span class="s">Linux</span><span class="nv"> </span><span class="s">Bundle"</span>
    <span class="na">strategy</span><span class="pi">:</span>
      <span class="c1"># symbol `g_once_init_enter_pointer`` was introduced in glib&gt;=2.80,</span>
      <span class="c1"># fallback build host to ubuntu-22.04 for better compatibility.</span>
      <span class="c1"># refs: https://github.com/cirruslabs/docker-images-flutter/issues/337.</span>
      <span class="na">matrix</span><span class="pi">:</span>
        <span class="na">variant</span><span class="pi">:</span>
          <span class="pi">-</span> <span class="na">arch</span><span class="pi">:</span> <span class="s">x86_64</span>
            <span class="na">runner</span><span class="pi">:</span> <span class="s">ubuntu-22.04</span>
            <span class="na">bundle_path</span><span class="pi">:</span> <span class="s">build/linux/x64/release/bundle</span>
          <span class="pi">-</span> <span class="na">arch</span><span class="pi">:</span> <span class="s">aarch64</span>
            <span class="na">runner</span><span class="pi">:</span> <span class="s">ubuntu-22.04-arm</span>
            <span class="na">bundle_path</span><span class="pi">:</span> <span class="s">build/linux/arm64/release/bundle</span>
    <span class="na">runs-on</span><span class="pi">:</span> <span class="s">${{ matrix.variant.runner }}</span>
    <span class="na">steps</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Checkout</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">actions/checkout@v4</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Init Flutter</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">...</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Build Linux Bundle</span>
        <span class="na">env</span><span class="pi">:</span>
          <span class="na">BUNDLE_PATH</span><span class="pi">:</span> <span class="s">${{ matrix.variant.bundle_path }}</span>
        <span class="na">run</span><span class="pi">:</span> <span class="s">flutter build linux --release</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Upload Bundle</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">actions/upload-artifact@v4</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">name</span><span class="pi">:</span> <span class="s">linux-bundle-${{ matrix.variant.arch }}</span>
          <span class="na">path</span><span class="pi">:</span> <span class="s">${{ matrix.variant.bundle_path }}</span>
          <span class="na">if-no-files-found</span><span class="pi">:</span> <span class="s">error</span>
          <span class="na">retention-days</span><span class="pi">:</span> <span class="m">1</span>

  <span class="c1"># Step2: 打包为 Flatpak Bundle</span>
  <span class="na">build-linux-flatpak</span><span class="pi">:</span>
    <span class="na">name</span><span class="pi">:</span> <span class="s2">"</span><span class="s">Build</span><span class="nv"> </span><span class="s">Flatpak</span><span class="nv"> </span><span class="s">Bundle"</span>
    <span class="na">needs</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">build-linux</span>
    <span class="na">container</span><span class="pi">:</span>
      <span class="c1"># https://github.com/flathub-infra/actions-images/pkgs/container/flatpak-github-actions</span>
      <span class="c1"># 根据对应 runtime 选择</span>
      <span class="na">image</span><span class="pi">:</span> <span class="s">ghcr.io/flathub-infra/flatpak-github-actions:freedesktop-23.08</span>
      <span class="na">options</span><span class="pi">:</span> <span class="s">--privileged</span>
    <span class="na">strategy</span><span class="pi">:</span>
      <span class="na">matrix</span><span class="pi">:</span>
        <span class="na">variant</span><span class="pi">:</span>
          <span class="pi">-</span> <span class="na">arch</span><span class="pi">:</span> <span class="s">x86_64</span>
            <span class="na">runner</span><span class="pi">:</span> <span class="s">ubuntu-22.04</span>
            <span class="na">bundle_path</span><span class="pi">:</span> <span class="s">build/linux/x64/release/bundle</span>
          <span class="pi">-</span> <span class="na">arch</span><span class="pi">:</span> <span class="s">aarch64</span>
            <span class="na">runner</span><span class="pi">:</span> <span class="s">ubuntu-22.04-arm</span>
            <span class="na">bundle_path</span><span class="pi">:</span> <span class="s">build/linux/arm64/release/bundle</span>
    <span class="na">runs-on</span><span class="pi">:</span> <span class="s">${{ matrix.variant.runner }}</span>
    <span class="na">env</span><span class="pi">:</span>
      <span class="na">ARTIFACT_NAME</span><span class="pi">:</span> <span class="s">linux-bundle-${{ matrix.variant.arch }}</span>
      <span class="na">FLATPAK_BUNDLE</span><span class="pi">:</span> <span class="s">output-${{ matrix.variant.arch }}.flatpak</span>
    <span class="na">steps</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">uses</span><span class="pi">:</span> <span class="s">actions/checkout@v4</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Fetch Bundle</span>
        <span class="na">id</span><span class="pi">:</span> <span class="s">fetch-bundle-step</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">actions/download-artifact@v4</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">name</span><span class="pi">:</span> <span class="s">${{ env.ARTIFACT_NAME }}</span>
          <span class="na">path</span><span class="pi">:</span> <span class="s">${{ matrix.variant.bundle_path }}</span>
          <span class="c1"># 填写一个可用的 Github Token，否则可能找到 API 限制</span>
          <span class="c1"># github-token: ${{ secrets.APP_RELEASE_TOKEN }}</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Build .flatpak</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">flatpak/flatpak-github-actions/flatpak-builder@v6</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">bundle</span><span class="pi">:</span> <span class="s">${{ env.FLATPAK_BUNDLE }}</span>
          <span class="na">manifest-path</span><span class="pi">:</span> <span class="s">/path/to/manifest.yml</span>
          <span class="na">arch</span><span class="pi">:</span> <span class="s">${{ matrix.variant.arch }}</span>
          <span class="c1"># branch: main</span>
          <span class="na">upload-artifact</span><span class="pi">:</span> <span class="no">false</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Release</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">ncipollo/release-action@v1</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">allowUpdates</span><span class="pi">:</span> <span class="no">true</span>
          <span class="na">omitBodyDuringUpdate</span><span class="pi">:</span> <span class="no">true</span>
          <span class="na">omitDraftDuringUpdate</span><span class="pi">:</span> <span class="no">true</span>
          <span class="na">omitPrereleaseDuringUpdate</span><span class="pi">:</span> <span class="no">true</span>
          <span class="na">artifacts</span><span class="pi">:</span> <span class="pi">&gt;</span>
            <span class="s">${{ env.FLATPAK_BUNDLE }}</span>
          <span class="c1"># 填写一个可用的 Github Token，否则可能找到 API 限制</span>
          <span class="c1"># token: ${{ secrets.APP_RELEASE_TOKEN }}</span>
</code></pre></div></div>

<h2 id="结语">结语</h2>

<p>以上，便能构造一个 <code class="language-plaintext highlighter-rouge">output.flatpak</code> 文件，
如果一切正常的话可以通过 <code class="language-plaintext highlighter-rouge">flatpak install --user output.flatpak</code> 进行安装验证。</p>

<p>下一篇文章会介绍如何以正确的姿势发布到 <code class="language-plaintext highlighter-rouge">Flathub</code>，由于 <code class="language-plaintext highlighter-rouge">Flathub</code> 自身有一些特殊发布规则，因此流程也会不太一样。
不过 <code class="language-plaintext highlighter-rouge">Flathub</code> 建议由上游自行维护 <code class="language-plaintext highlighter-rouge">metainfo.xml</code> 文件，
因此建议从一开始就保持自己发布的 <code class="language-plaintext highlighter-rouge">metainfo.xml</code> 与 <code class="language-plaintext highlighter-rouge">Flathub</code> 要求兼容，具体 lint 检查放在下篇文章中。</p>

<ul>
  <li><a href="/post/202507/flutter-flatpak-and-flathub-2">为 Flutter 应用打包为 Flatpak 并以正确的姿势上架到 Flathub (下)</a></li>
</ul>

<!-- refs -->]]></content><author><name>Fries_I23</name></author><category term="flutter" /><category term="flutter" /><category term="flutter-linux" /><category term="flatpak" /><category term="flatpak-bundle" /><category term="flathub" /><summary type="html"><![CDATA[写在 Table Habit 上架到 Flathub 后，主要作为记录和供其他有需要者进行参考。 共分为两个部分，本文为上半部分：既如何打包自己的 Single-file bundles。]]></summary></entry><entry><title type="html">Flutter macOS 应用构建：GitHub Actions + 自动管理签名 (Automatically Manage Signing)</title><link href="https://friesi23.icu/post/202504/github-action-macos-app-with-auto-sign" rel="alternate" type="text/html" title="Flutter macOS 应用构建：GitHub Actions + 自动管理签名 (Automatically Manage Signing)" /><published>2025-04-11T00:00:00+00:00</published><updated>2025-04-11T00:00:00+00:00</updated><id>https://friesi23.icu/post/202504/github-action-macos-app-with-auto-sign</id><content type="html" xml:base="https://friesi23.icu/post/202504/github-action-macos-app-with-auto-sign"><![CDATA[<p>Github 官方文档 <a href="https://docs.github.com/en/actions/use-cases-and-examples/deploying/installing-an-apple-certificate-on-macos-runners-for-xcode-development">“Installing an Apple certificate on macOS runners for Xcode development”</a>
阐述了如何在 macOS 环境中装载 Apple 证书以便为代码签名。不过该文档基于手动管理<code class="language-plaintext highlighter-rouge">配置文件（Provision Proile）</code>，
而对于启用<code class="language-plaintext highlighter-rouge">自动管理签名（Automatically manage signing）</code>的项目，由于使用<code class="language-plaintext highlighter-rouge">云签名（Cloud signing）</code>，
因此无需创建该配置文件。</p>

<p>而在 Xcode 13 及更高版本中，<code class="language-plaintext highlighter-rouge">xcodebuild</code> 可以使用 App Store Connect API 在 Apple Developer 上进行身份验证。
因此我们可以使用该验证方式在 CI 中对代码进行自动签名。</p>

<h2 id="需要准备">需要准备</h2>

<ol>
  <li>一个 Apple 开发者账号。</li>
  <li>一个 App Store Connect API 对应的团队密钥（导出为 <code class="language-plaintext highlighter-rouge">P8</code> 文件）。
    <ul>
      <li>Issuer ID</li>
      <li>API Key</li>
      <li>API Certificate (<code class="language-plaintext highlighter-rouge">Auth_xxx.p8</code>)</li>
    </ul>
  </li>
  <li>一个 Developer Id Application Certificate（导出为 <code class="language-plaintext highlighter-rouge">P12</code> 文件）。
    <ul>
      <li>Certificate (<code class="language-plaintext highlighter-rouge">***.p12</code>)</li>
      <li>Certificate Password</li>
    </ul>
  </li>
</ol>

<h3 id="app-store-connect-api-团队密钥">App Store Connect API 团队密钥</h3>

<p>访问 <a href="https://appstoreconnect.apple.com/access/integrations/api">App Store Connect / 用户和访问 / App Store Connect API</a>，
选择 <code class="language-plaintext highlighter-rouge">团队密钥</code>，然后添加一个新的密钥，完成后下载 <code class="language-plaintext highlighter-rouge">p8</code> 证书。</p>

<p>此时我们拥有以下三个需要的信息：</p>

<ol>
  <li>Issuer ID: 可以从<strong>“团队密钥”</strong>下找到。</li>
  <li>API Key: 可以从<strong>“团队密钥 / 有效”</strong>下找到（中文名：密钥 ID）。</li>
  <li>API Certificate：上面下载的文件，由于只能下载一次，请妥善保存。</li>
</ol>

<h3 id="developer-id-application-certificate">Developer Id Application Certificate</h3>

<p>一个快捷的生成方式：直接在 Xcode 中进行生成。</p>

<ol>
  <li>导航到 <code class="language-plaintext highlighter-rouge">Xcode --&gt; Settings... --&gt; Accounts --&gt; Manage Certificates...</code>。</li>
  <li>点击左下角 <code class="language-plaintext highlighter-rouge">+</code>，点击 <code class="language-plaintext highlighter-rouge">Develop ID Application</code>，等待创建完成。</li>
  <li>找到刚刚创建的 Certificate，右键单击，选取 <code class="language-plaintext highlighter-rouge">Export Certificate</code>。</li>
  <li>导出时需要密码，随机生成一个即可，记得记录这个密码，最后会生成一个 <code class="language-plaintext highlighter-rouge">p12</code> 文件。</li>
</ol>

<p><img src="https://cdn.jsdelivr.net/gh/FriesI23/blog-image@master/img/github-action-macos-app-with-auto-sign-pic-1.png" alt="pic-1" width="800" />
<img src="https://cdn.jsdelivr.net/gh/FriesI23/blog-image@master/img/github-action-macos-app-with-auto-sign-pic-2.png" alt="pic-2" width="800" /></p>

<p>此时我们拥有以下两个需要的信息：</p>

<ol>
  <li>Certificate: 刚刚导出的 <code class="language-plaintext highlighter-rouge">p12</code> 文件。</li>
  <li>Certificate Password: 导出时输入的密码。</li>
</ol>

<h3 id="github-action-secret-keys">Github Action Secret Keys</h3>

<p>将上面的信息存储到 <code class="language-plaintext highlighter-rouge">Repository secrets</code> 中，其中两个文件使用 <code class="language-plaintext highlighter-rouge">base64</code> 进行编码。</p>

<div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">base64</span> <span class="nt">-i</span> <span class="k">${</span><span class="nv">your</span><span class="p">-api-auth</span><span class="k">}</span>.p8 | pbcopy
<span class="nb">base64</span> <span class="nt">-i</span> <span class="k">${</span><span class="nv">your</span><span class="p">-certificate</span><span class="k">}</span>.p12 | pbcopy
</code></pre></div></div>

<p>同时我们需要一个 <code class="language-plaintext highlighter-rouge">APPLE_KEYCHAIN_PASSWORD</code>，后续导入证书时需要使用，随机生成一个字符串填入即可。</p>

<p><img src="https://cdn.jsdelivr.net/gh/FriesI23/blog-image@master/img/github-action-macos-app-with-auto-sign-pic-3.png" alt="pic-3" width="800" /></p>

<h2 id="github-action-流程">Github Action 流程</h2>

<h3 id="导入证书">导入证书</h3>

<p>使用以下步骤进行证书导入，这里和 Github 官方文档的区别在于：我们不需要导入<code class="language-plaintext highlighter-rouge">配置文件（Provision Profile）</code>。</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">jobs/&lt;build-app&gt;/steps</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Import Certificate</span>
    <span class="na">env</span><span class="pi">:</span>
      <span class="na">KEYCHAIN_PASSWORD</span><span class="pi">:</span> <span class="s">$</span>
    <span class="na">run</span><span class="pi">:</span> <span class="pi">|</span>
      <span class="s"># create variables</span>
      <span class="s">CERTIFICATE_PATH=$RUNNER_TEMP/certificate.p12</span>
      <span class="s">KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db</span>
      <span class="s"># import certificate from secrets</span>
      <span class="s">echo "$" | base64 --decode &gt; $CERTIFICATE_PATH</span>
      <span class="s"># create temporary keychain</span>
      <span class="s">security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH</span>
      <span class="s">security set-keychain-settings -lut 21600 $KEYCHAIN_PATH</span>
      <span class="s">security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH</span>
      <span class="s"># import certificate to keychain</span>
      <span class="s">security import $CERTIFICATE_PATH \</span>
        <span class="s">-k $KEYCHAIN_PATH \</span>
        <span class="s">-P "$" \</span>
        <span class="s">-A -t cert -f pkcs12</span>
      <span class="s">security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH</span>
      <span class="s">security list-keychain -d user -s $KEYCHAIN_PATH</span>
</code></pre></div></div>

<h3 id="导入-api-key">导入 API Key</h3>

<p>由于使用配置文件，我们需要使用 “App Store Connect API”，因此需要导入 <code class="language-plaintext highlighter-rouge">p8</code> 文件：</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">jobs/&lt;build-app&gt;/steps</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Extract App Store Connect API Key</span>
    <span class="na">env</span><span class="pi">:</span>
      <span class="na">APPLE_API_KEY_ID</span><span class="pi">:</span> <span class="s">$</span>
      <span class="na">APPLE_API_AUTHKEY_P8_BASE64</span><span class="pi">:</span> <span class="s">$</span>
    <span class="na">run</span><span class="pi">:</span> <span class="pi">|</span>
      <span class="s">mkdir ./private_keys</span>
      <span class="s">echo -n "$APPLE_API_AUTHKEY_P8_BASE64" | base64 --decode --output ./private_keys/AuthKey_$APPLE_API_KEY_ID.p8</span>
</code></pre></div></div>

<h3 id="手动构建">手动构建</h3>

<p>由于我们需要手动签名，因此不能直接使用 <code class="language-plaintext highlighter-rouge">flutter build macos --release</code> 进行构建（会报“找不到配置文件”的错误）。
此时我们需要手动运行构建命名，如下：</p>

<blockquote>
  <p>由于后续签名需要，这里直接导出归档文件。</p>
</blockquote>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">jobs/&lt;build-app&gt;/steps</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Build APP</span>
    <span class="na">env</span><span class="pi">:</span>
      <span class="na">APPLE_TEAM_ID</span><span class="pi">:</span> <span class="s">$</span>
    <span class="na">run</span><span class="pi">:</span> <span class="pi">|</span>
      <span class="s">flutter build macos --release --config-only</span>
      <span class="s">xcodebuild CODE_SIGNING_ALLOWED=NO \</span>
        <span class="s">-workspace macos/Runner.xcworkspace \</span>
        <span class="s">-scheme Runner \</span>
        <span class="s">-configuration Release \</span>
        <span class="s">-archivePath build/macos/Runner.xcarchive \</span>
        <span class="s">archive</span>
      <span class="s"># ls -al build/macos/Runner.xcarchive/Products/Applications</span>
</code></pre></div></div>

<h3 id="签名应用">签名应用</h3>

<p>签名导出时需要使用 <code class="language-plaintext highlighter-rouge">xcodebuild -exportArchive</code> 命名，此时需要手动创建一个 <code class="language-plaintext highlighter-rouge">ExportOptions.plist</code> 文件。
该文件的具体格式可 GUI 创建方法参考<a href="https://developer.apple.com/documentation/xcode/distributing-your-app-for-beta-testing-and-releases"><strong>官方文档</strong></a>，这里只给出一个示例：</p>

<blockquote>
  <p>注意 plist 本身并不支持诸如 <code class="language-plaintext highlighter-rouge">&lt;string&gt;${APPLE_TEAM_ID}&lt;/string&gt;</code> 之类的格式，这里是作为一个 template，
由 <code class="language-plaintext highlighter-rouge">envsubst</code> 进行变量替换生成真正的 plist 文件。</p>
</blockquote>

<div class="language-xml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">&lt;?xml version="1.0" encoding="UTF-8"?&gt;</span>
<span class="cp">&lt;!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"&gt;</span>
<span class="nt">&lt;plist</span> <span class="na">version=</span><span class="s">"1.0"</span><span class="nt">&gt;</span>
  <span class="nt">&lt;dict&gt;</span>
    <span class="nt">&lt;key&gt;</span>manageAppVersionAndBuildNumber<span class="nt">&lt;/key&gt;</span>
    <span class="nt">&lt;false/&gt;</span>
    <span class="nt">&lt;key&gt;</span>method<span class="nt">&lt;/key&gt;</span>
    <span class="nt">&lt;string&gt;</span>developer-id<span class="nt">&lt;/string&gt;</span>
    <span class="nt">&lt;key&gt;</span>teamID<span class="nt">&lt;/key&gt;</span>
    <span class="nt">&lt;string&gt;</span>${APPLE_TEAM_ID}<span class="nt">&lt;/string&gt;</span>
  <span class="nt">&lt;/dict&gt;</span>
<span class="nt">&lt;/plist&gt;</span>
</code></pre></div></div>

<p>这里注意需要使用 <code class="language-plaintext highlighter-rouge">developer-id</code>，该方法依赖 <code class="language-plaintext highlighter-rouge">Developer Id Application Certificate</code>，而我们在上面已经进行导入，
参考<a href="#导入证书">“导入证书”</a>。</p>

<p>我们假定将该文件存放在 <code class="language-plaintext highlighter-rouge">./installers/macos_exporter/GithubExportOptions.plist</code>，构建以下步骤：</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">jobs/&lt;build-app&gt;/steps</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Signed APP</span>
    <span class="na">env</span><span class="pi">:</span>
      <span class="na">APPLE_API_ISSUER_ID</span><span class="pi">:</span> <span class="s">$</span>
      <span class="na">APPLE_API_KEY_ID</span><span class="pi">:</span> <span class="s">$</span>
      <span class="na">APPLE_TEAM_ID</span><span class="pi">:</span> <span class="s">$</span>
    <span class="na">run</span><span class="pi">:</span> <span class="pi">|</span>
      <span class="s">envsubst \</span>
        <span class="s">&lt; ./installers/macos_exporter/GithubExportOptions.plist \</span>
        <span class="s">&gt; ./installers/macos_exporter/GithubExportOptions.resolved.plist</span>
      <span class="s">cat ./installers/macos_exporter/GithubExportOptions.resolved.plist</span>
      <span class="s">plutil -lint ./installers/macos_exporter/GithubExportOptions.resolved.plist</span>
      <span class="s">xcodebuild -exportArchive -archivePath ./build/macos/Runner.xcarchive \</span>
        <span class="s">-exportPath ./build/macos/Build/Products/Release \</span>
        <span class="s">-exportOptionsPlist ./installers/macos_exporter/GithubExportOptions.resolved.plist \</span>
        <span class="s">-allowProvisioningUpdates \</span>
        <span class="s">-authenticationKeyIssuerID $APPLE_API_ISSUER_ID \</span>
        <span class="s">-authenticationKeyID $APPLE_API_KEY_ID \</span>
        <span class="s">-authenticationKeyPath `pwd`/private_keys/AuthKey_$APPLE_API_KEY_ID.p8</span>
</code></pre></div></div>

<p>最终我们将签名的应用导出到 <code class="language-plaintext highlighter-rouge">./build/macos/Build/Products/Release</code>。</p>

<h2 id="整体流程">整体流程</h2>

<p>这里直接粘贴自己项目中的流程，仅供参考：</p>

<blockquote>
  <p><a href="https://github.com/FriesI23/mhabit/blob/a57159998792d5c7890c690e6340b11100bac590/.github/workflows/app-release.yml"><strong>Workflow Source</strong></a></p>
</blockquote>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">jobs</span><span class="pi">:</span>
  <span class="c1"># other actions</span>
  <span class="na">build-macos-dmg</span><span class="pi">:</span>
    <span class="na">name</span><span class="pi">:</span> <span class="s2">"</span><span class="s">Build</span><span class="nv"> </span><span class="s">macos</span><span class="nv"> </span><span class="s">DMG"</span>
    <span class="na">runs-on</span><span class="pi">:</span> <span class="s">macos-latest</span>
    <span class="na">steps</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">uses</span><span class="pi">:</span> <span class="s">maxim-lobanov/setup-xcode@v1</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">xcode-version</span><span class="pi">:</span> <span class="s">^16</span>
      <span class="pi">-</span> <span class="na">uses</span><span class="pi">:</span> <span class="s">actions/checkout@v4</span>
      <span class="pi">-</span> <span class="na">uses</span><span class="pi">:</span> <span class="s">./.github/actions/setup_flutter</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Import Certificate</span>
        <span class="na">env</span><span class="pi">:</span>
          <span class="na">KEYCHAIN_PASSWORD</span><span class="pi">:</span> <span class="s">$</span>
        <span class="na">run</span><span class="pi">:</span> <span class="pi">|</span>
          <span class="s"># refs: https://docs.github.com/en/actions/use-cases-and-examples/deploying/installing-an-apple-certificate-on-macos-runners-for-xcode-development</span>
          <span class="s"># create variables</span>
          <span class="s">CERTIFICATE_PATH=$RUNNER_TEMP/certificate.p12</span>
          <span class="s">KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db</span>
          <span class="s"># import certificate from secrets</span>
          <span class="s">echo "$" | base64 --decode &gt; $CERTIFICATE_PATH</span>
          <span class="s"># create temporary keychain</span>
          <span class="s">security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH</span>
          <span class="s">security set-keychain-settings -lut 21600 $KEYCHAIN_PATH</span>
          <span class="s">security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH</span>
          <span class="s"># import certificate to keychain</span>
          <span class="s">security import $CERTIFICATE_PATH \</span>
            <span class="s">-k $KEYCHAIN_PATH \</span>
            <span class="s">-P "$" \</span>
            <span class="s">-A -t cert -f pkcs12</span>
          <span class="s">security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH</span>
          <span class="s">security list-keychain -d user -s $KEYCHAIN_PATH</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Extract App Store Connect API Key</span>
        <span class="na">env</span><span class="pi">:</span>
          <span class="na">APPLE_API_KEY_ID</span><span class="pi">:</span> <span class="s">$</span>
          <span class="na">APPLE_API_AUTHKEY_P8_BASE64</span><span class="pi">:</span> <span class="s">$</span>
        <span class="na">run</span><span class="pi">:</span> <span class="pi">|</span>
          <span class="s">mkdir ./private_keys</span>
          <span class="s">echo -n "$APPLE_API_AUTHKEY_P8_BASE64" | base64 --decode --output ./private_keys/AuthKey_$APPLE_API_KEY_ID.p8</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Build APP</span>
        <span class="na">env</span><span class="pi">:</span>
          <span class="na">APPLE_TEAM_ID</span><span class="pi">:</span> <span class="s">$</span>
        <span class="na">run</span><span class="pi">:</span> <span class="pi">|</span>
          <span class="s">flutter build macos --release --config-only</span>
          <span class="s">xcodebuild CODE_SIGNING_ALLOWED=NO \</span>
            <span class="s">-workspace macos/Runner.xcworkspace \</span>
            <span class="s">-scheme Runner \</span>
            <span class="s">-configuration Release \</span>
            <span class="s">-archivePath build/macos/Runner.xcarchive \</span>
            <span class="s">archive</span>
          <span class="s">ls -al build/macos/Runner.xcarchive/Products/Applications</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Signed APP</span>
        <span class="na">env</span><span class="pi">:</span>
          <span class="na">APPLE_API_ISSUER_ID</span><span class="pi">:</span> <span class="s">$</span>
          <span class="na">APPLE_API_KEY_ID</span><span class="pi">:</span> <span class="s">$</span>
          <span class="na">APPLE_TEAM_ID</span><span class="pi">:</span> <span class="s">$</span>
        <span class="na">run</span><span class="pi">:</span> <span class="pi">|</span>
          <span class="s">envsubst \</span>
            <span class="s">&lt; ./installers/macos_exporter/GithubExportOptions.plist \</span>
            <span class="s">&gt; ./installers/macos_exporter/GithubExportOptions.resolved.plist</span>
          <span class="s">cat ./installers/macos_exporter/GithubExportOptions.resolved.plist</span>
          <span class="s">plutil -lint ./installers/macos_exporter/GithubExportOptions.resolved.plist</span>
          <span class="s">xcodebuild -exportArchive -archivePath ./build/macos/Runner.xcarchive \</span>
            <span class="s">-exportPath ./build/macos/Build/Products/Release \</span>
            <span class="s">-exportOptionsPlist ./installers/macos_exporter/GithubExportOptions.resolved.plist \</span>
            <span class="s">-allowProvisioningUpdates \</span>
            <span class="s">-authenticationKeyIssuerID $APPLE_API_ISSUER_ID \</span>
            <span class="s">-authenticationKeyID $APPLE_API_KEY_ID \</span>
            <span class="s">-authenticationKeyPath `pwd`/private_keys/AuthKey_$APPLE_API_KEY_ID.p8</span>
      <span class="pi">-</span> <span class="na">uses</span><span class="pi">:</span> <span class="s">actions/setup-node@v4</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">node-version</span><span class="pi">:</span> <span class="m">20</span>
          <span class="na">token</span><span class="pi">:</span> <span class="s">$</span>
      <span class="pi">-</span> <span class="na">run</span><span class="pi">:</span> <span class="s">npm install -g appdmg</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Build DMP</span>
        <span class="na">run</span><span class="pi">:</span> <span class="s">appdmg ./installers/dmg_creator/config.json ./build/macos/Build/Products/Release/mhabit.dmg</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Released - MacOS</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">ncipollo/release-action@v1</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">allowUpdates</span><span class="pi">:</span> <span class="no">true</span>
          <span class="na">omitBodyDuringUpdate</span><span class="pi">:</span> <span class="no">true</span>
          <span class="na">omitDraftDuringUpdate</span><span class="pi">:</span> <span class="no">true</span>
          <span class="na">omitPrereleaseDuringUpdate</span><span class="pi">:</span> <span class="no">true</span>
          <span class="na">artifacts</span><span class="pi">:</span> <span class="pi">&gt;</span>
            <span class="s">build/macos/Build/Products/Release/mhabit.dmg</span>
          <span class="na">token</span><span class="pi">:</span> <span class="s">$</span>
</code></pre></div></div>

<h2 id="问题应用签名时出现-segmentation-fault-11">问题：应用签名时出现 Segmentation fault: 11</h2>

<p>参考该 ISSUE：<a href="https://github.com/feedback-assistant/reports/issues/562">FB13797668: xcodebuild crashes systematically when exporting an archive (segfault)</a></p>

<p>该问题已在 <code class="language-plaintext highlighter-rouge">Xcode 16 Beta 4</code> 进行修复。如果出现该问题，请指定执行时的 Xcode 版本（而不是使用容器自带的版本）：</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">jobs/...</span><span class="pi">:</span>
  <span class="c1"># https://github.com/maxim-lobanov/setup-xcode</span>
  <span class="pi">-</span> <span class="na">uses</span><span class="pi">:</span> <span class="s">maxim-lobanov/setup-xcode@v1</span>
    <span class="na">with</span><span class="pi">:</span>
      <span class="na">xcode-version</span><span class="pi">:</span> <span class="s">^16</span>  <span class="c1">#</span>
</code></pre></div></div>

<h2 id="参考资料">参考资料</h2>

<ol>
  <li><a href="https://zenn.dev/yorifuji/articles/build-automatically-manage-singin-on-ci">GitHub Actions で Automatically manage signing を使って Flutter の ipa ビルドする</a></li>
  <li><a href="https://developer.apple.com/documentation/xcode/distributing-your-app-for-beta-testing-and-releases">Distributing your app for beta testing and releases</a></li>
</ol>

<!-- refs -->]]></content><author><name>Fries_I23</name></author><category term="deploy" /><category term="flutter" /><category term="github-action" /><category term="macos" /><category term="ci/cd" /><category term="code-signing" /><summary type="html"><![CDATA[在 GitHub Actions 中构建并自动签名 macOS Flutter 应用， 使用 App Store Connect API 和 Developer ID Certificate 实现自动化发布流程。]]></summary></entry><entry><title type="html">「王婆卖瓜」在 OpenWRT 中使用 DNSPod 动态解析域名</title><link href="https://friesi23.icu/post/202501/openwrt-ddns-scripts-dnspod-v3" rel="alternate" type="text/html" title="「王婆卖瓜」在 OpenWRT 中使用 DNSPod 动态解析域名" /><published>2025-01-28T00:00:00+00:00</published><updated>2025-01-28T00:00:00+00:00</updated><id>https://friesi23.icu/post/202501/openwrt-ddns-scripts-dnspod-v3</id><content type="html" xml:base="https://friesi23.icu/post/202501/openwrt-ddns-scripts-dnspod-v3"><![CDATA[<p>一直使用 DNSPod 提供的域名服务，前阵子准备将自己服务器的更新脚本移动到主路由上（OpenWRT）。
本着不重复造轮子的原则，在网上搜索了一波，发现了一些可用的 Package。不过看起来多多少少都有点问题，
这里在下面先简要列举一下：</p>

<ul>
  <li><a href="https://github.com/Tencent-Cloud-Plugins/tencentcloud-openwrt-plugin-ddns"><code class="language-plaintext highlighter-rouge">tencentcloud-openwrt-plugin-ddns</code></a>: 官方插件，不过似乎很久没有更新了，
很多 Issue 也没有得到回复，看起来不是很可靠。</li>
  <li><a href="https://github.com/nixonli/ddns-scripts_dnspod"><code class="language-plaintext highlighter-rouge">ddns-scripts_dnspod</code></a>: 基于 <code class="language-plaintext highlighter-rouge">ddns-scripts</code> 的脚本，试了下确实可行，不过有几个问题：
    <ol>
      <li>代码内部实现比较简陋，没有遵守 <code class="language-plaintext highlighter-rouge">ddns-scripts</code> 中自定义脚本相对标准的写法（参考 <code class="language-plaintext highlighter-rouge">dynamic_dns_updater.sh</code> 脚本）。</li>
      <li>使用 v2 版本的 API，由于 DNSPod 已被腾讯收购，而整个服务已经整合到腾讯云中，现在最新的 API 为 v3 版本，
v2 已经标记为<strong>废弃</strong>，因此使用这版 API 存在一定的风险。</li>
    </ol>
  </li>
  <li><a href="https://github.com/AllanChain/qcloud-ddns-docker"><code class="language-plaintext highlighter-rouge">qcloud-ddns-docker</code></a>: 使用 <code class="language-plaintext highlighter-rouge">tccli</code>, 且要部署 Docker，因此不想考虑。</li>
</ul>

<p>基于上面各种方案的问题，因此决定自己为 <code class="language-plaintext highlighter-rouge">ddns-scripts</code> 写一个基于 v3 API 的脚本，因此有了如下项目：</p>

<h2 id="friesi23ddns-scripts_tencent_cloud">FriesI23/ddns-scripts_tencent_cloud</h2>

<p>项目地址：<a href="https://github.com/FriesI23/ddns-scripts_tencent_cloud"><code class="language-plaintext highlighter-rouge">ddns-scripts_tencent_cloud</code></a></p>

<p>该脚本基于 <code class="language-plaintext highlighter-rouge">ddns-scripts</code> 项目，并且基于腾讯云 v3 API 进行编写，提供以下支持：</p>

<ul>
  <li>同时支持 IPv4 / IPv6</li>
  <li>支持更新解析值 / 为新域名增加解析 (新增需保证查询主机名为被解析域名).</li>
</ul>

<p>具体使用方法可以查看 <a href="https://github.com/FriesI23/ddns-scripts_tencent_cloud/blob/master/README.md"><code class="language-plaintext highlighter-rouge">README.md</code></a>，
当然最简单的方法就是在 Releases 界面找到最新的 <code class="language-plaintext highlighter-rouge">*.ipk*</code> 上传到 OpenWRT 进行安装。</p>

<h2 id="openwrtpackageddns-scripts">openwrt/package/ddns-scripts</h2>

<p>该项目已同步提交到 <a href="https://github.com/openwrt/packages/blob/master/net/ddns-scripts/files/usr/lib/ddns/update_dnspod_cn_v3.sh"><code class="language-plaintext highlighter-rouge">openwrt/package/ddns-scripts</code></a> 中，
如果你的 <code class="language-plaintext highlighter-rouge">ddns-scripts</code> 的 Build Number <code class="language-plaintext highlighter-rouge">&gt;=58</code>，则可以通过在搜索 <code class="language-plaintext highlighter-rouge">ddns-scripts-dnspod-v3</code> 直接进行安装.</p>

<p>截止 2025/01/30 的相关提交 PR：</p>

<ul>
  <li><a href="https://github.com/openwrt/packages/pull/25619">Add script (#25619)</a></li>
  <li><a href="https://github.com/openwrt/packages/pull/25748">Fix some bugs (#25748)</a></li>
</ul>

<!-- refs -->]]></content><author><name>Fries_I23</name></author><category term="tools" /><category term="openwrt" /><category term="ddns-scripts" /><category term="dnspod" /><category term="tencent-cloud" /><category term="ddns" /><summary type="html"><![CDATA[使用 ddns-scripts_tencent_cloud 为 OpenWRT 提供对 DNSPod 的 DDns 支持， 脚本使用最新的 v3 API，支持 IPV4 与 IPV6 地址，同时提供对 ddns-scripts LUCI 界面中的各种配置项的兼容。]]></summary></entry><entry><title type="html">记录 KVM 在使用 macvtap 提供网络后，IPV6 地址无法 Ping 通的问题</title><link href="https://friesi23.icu/post/202501/ipv6-not-working-on-macvtap" rel="alternate" type="text/html" title="记录 KVM 在使用 macvtap 提供网络后，IPV6 地址无法 Ping 通的问题" /><published>2025-01-06T01:00:00+00:00</published><updated>2025-01-06T01:00:00+00:00</updated><id>https://friesi23.icu/post/202501/ipv6-not-working-on-macvtap</id><content type="html" xml:base="https://friesi23.icu/post/202501/ipv6-not-working-on-macvtap"><![CDATA[<p>最近准备将家里面的 <code class="language-plaintext highlighter-rouge">Home Assistant</code> IPV6 GUA 地址配置到域名，设置时候突然发现 HA 的 IPV6 地址无法除了本机外都无法 Ping 通。
最后发现竟然是 KVM 中 macvtap 的默认配置导致的问题，故在这里简单记录一下解决方法。</p>

<p>下面的方案也适用于所有使用 KVM 部署虚拟机并由 macvtap 提供 host 网络接口的情况。</p>

<h2 id="ipv6-中的邻居发现nd">IPV6 中的邻居发现（ND)</h2>

<p>IPV6 与 IPV4 的 ARP 不同，<code class="language-plaintext highlighter-rouge">ND</code> 由组播（multicast) 而不是广播（broadcast）。
这里的技术细节就不在本文赘述了，我们只需要知道 IPV6 与 IPV4 路由发现的机制并不相同。</p>

<h2 id="排查过程">排查过程</h2>

<p>发现 HA 中的 IPV6 地址无效后，首先怀疑是网络适配器配置错误，但是检查 Interface 相关配置后并没有发现异常。
因此对同一宿主机上的其他不同主机也进行的检查，发现：</p>

<ul>
  <li>KVM 下使用 host 的客户端都存在 IPV6 无法 ping 通，甚至无法访问的情况
（由于网络栈都是用了双栈协议，在不特意需求 IPV6 的前提下，失效并不会对网络访问产生影响，故一直没有发现）。</li>
  <li>其他使用 Docker 或者 LXC 托管的服务都正常。</li>
</ul>

<p>将问题缩小后 KVM Guest 后，由于 IPV6 地址创建正常（个人网络使用 SLAAC 由 RA ISP 提供 PD 委托），因此怀疑是路由表有问题。
使用 <code class="language-plaintext highlighter-rouge">ip -6 route show</code> 查看后，果然对应的 Interface 上没有对应路由条目，因此可以初步判断问题出在邻居发现（ND）上。</p>

<p>尝试在 Guest 中进行抓包，发现所有虚拟机内都没有收到任何 NS 报文。</p>

<h2 id="解决问题修复-multicast">解决问题（修复 Multicast）</h2>

<p>最终在 <a href="https://superuser.com/a/1033768">“How to configure macvtap to let it pass multicast packet correctly?”</a> 找到了解决方案。
下面引用一段原文解释上述问题为什么会产生：</p>

<blockquote>
  <p>libvirt defaults to trustGuestRxFilters=no. This is documented to ignore the guest “interface mac address and receive filters”.
This is regrettably obscure; the commit message is slightly clearer: “interface’s mac address and unicast/multicast filters”.</p>

  <p>– <a href="https://bugzilla.redhat.com/show_bug.cgi?id=1035253#c15">“IPv4/IPv6 multicasts are not forwarded via macvtap/macvlan bridge to VM (between VMs)”</a></p>
</blockquote>

<p>我们可以从上面看到，<code class="language-plaintext highlighter-rouge">libvirt</code> 默认会对 <code class="language-plaintext highlighter-rouge">macvtap/macvlan</code> 的多播进行过滤，这同样会过滤掉 IPV6 中邻居发现相关的报文，而 IPV6 本身依赖多播。
因此限制多播便会导致 IPV6 功能不完整。</p>

<p>解决方法也很简单，在虚拟机对应的 macvtap 接口上启用多播。下面会以一个虚拟机的 XML 片段进行简单举例：</p>

<div class="language-xml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">&lt;!-- Add `trustGuestRxFilters="yes"` on Interfaces you want enable multicast--&gt;</span>
<span class="nt">&lt;domain</span> <span class="na">type=</span><span class="s">'kvm'</span> <span class="na">id=</span><span class="s">'7'</span><span class="nt">&gt;</span>
  <span class="nt">&lt;name&gt;</span>vm<span class="nt">&lt;/name&gt;</span>
  <span class="nt">&lt;uuid&gt;</span>xxxx-xxxx-xxx<span class="nt">&lt;/uuid&gt;</span>
  <span class="c">&lt;!-- ... --&gt;</span>
  <span class="nt">&lt;devices&gt;</span>
    <span class="nt">&lt;emulator&gt;</span>/usr/bin/qemu-system-x86_64<span class="nt">&lt;/emulator&gt;</span>
    <span class="c">&lt;!-- ... --&gt;</span>
    <span class="nt">&lt;interface</span> <span class="na">type=</span><span class="s">'network'</span> <span class="na">trustGuestRxFilters=</span><span class="s">'yes'</span><span class="nt">&gt;</span>
      <span class="nt">&lt;mac</span> <span class="na">address=</span><span class="s">'6a:ca:1a:ff:3d:65'</span><span class="nt">/&gt;</span>
      <span class="nt">&lt;source</span> <span class="na">network=</span><span class="s">'host'</span><span class="nt">/&gt;</span>
      <span class="nt">&lt;model</span> <span class="na">type=</span><span class="s">'virtio'</span><span class="nt">/&gt;</span>
      <span class="nt">&lt;address</span> <span class="na">type=</span><span class="s">'pci'</span> <span class="na">domain=</span><span class="s">'0x0000'</span> <span class="na">bus=</span><span class="s">'0x00'</span> <span class="na">slot=</span><span class="s">'0x02'</span> <span class="na">function=</span><span class="s">'0x0'</span><span class="nt">/&gt;</span>
    <span class="nt">&lt;/interface&gt;</span>
    <span class="c">&lt;!-- ... --&gt;</span>
  <span class="nt">&lt;/devices&gt;</span>
  <span class="c">&lt;!-- ... --&gt;</span>
<span class="nt">&lt;/domain&gt;</span>
</code></pre></div></div>

<p>关闭虚拟机后，XML 添加对应属性，然后重启虚拟机，检查路由表正常，且能正常收到 <code class="language-plaintext highlighter-rouge">NS</code> 等报文，也能 Ping 通地址，问题解决。</p>

<h2 id="参考资料">参考资料</h2>

<ul>
  <li><a href="https://en.wikipedia.org/wiki/Neighbor_Discovery_Protocol">“Neighbor Discovery Protocol”</a></li>
  <li><a href="https://superuser.com/q/944678">“How to configure macvtap to let it pass multicast packet correctly?”</a></li>
  <li><a href="https://bugzilla.redhat.com/show_bug.cgi?id=1035253">“IPv4/IPv6 multicasts are not forwarded via macvtap/macvlan bridge to VM (between VMs)”</a></li>
</ul>

<!-- refs -->]]></content><author><name>Fries_I23</name></author><category term="network" /><category term="ipv6" /><category term="libvirt" /><category term="macvtap" /><category term="multicast" /><summary type="html"><![CDATA[在使用 KVM (libvirt) 配置 macvtap 网络时，由于默认多播通信的限制，可能导致客户端中的 IPv6 无法正常工作。 问题旨在记录并解决该问题，以恢复虚拟机中 IPv6 的正常功能。]]></summary></entry><entry><title type="html">解决家庭环境宽带断线重播后，有概率无法获取 IPV6 地址的问题</title><link href="https://friesi23.icu/post/202412/openwrt-losing-ipv6-upstream" rel="alternate" type="text/html" title="解决家庭环境宽带断线重播后，有概率无法获取 IPV6 地址的问题" /><published>2024-12-26T01:00:00+00:00</published><updated>2024-12-26T01:00:00+00:00</updated><id>https://friesi23.icu/post/202412/openwrt-losing-ipv6-upstream</id><content type="html" xml:base="https://friesi23.icu/post/202412/openwrt-losing-ipv6-upstream"><![CDATA[<p>最近为了家庭智能设备更好的使用，便在路由上开通了 IPV6。不过自从开通后，总是在凌晨运营商断线后重新分配 IP 时，
有一定概率在 <code class="language-plaintext highlighter-rouge">WAN_6</code> 接口上获取不到 IPV6 地址，但是可以获取到 <code class="language-plaintext highlighter-rouge">IPV6-DP</code>，也可以正常通过 IPV6 访问外网。</p>

<p>这个很烦人，因为我在路由上有做 DDNS，而 <code class="language-plaintext highlighter-rouge">WAN_6</code> 地址丢失会导致路由上的 DDNS 脚本无法获取到正确的地址而失败。
因此在晚上搜索了一圈，终于找到了解决方案，这里记录一下，也对一些搜索时可能无用的方案做一下记录。</p>

<h2 id="解决方法">解决方法</h2>

<p>一通搜索和尝试后，最终在<a href="https://github.com/hanwckf/immortalwrt-mt798x/issues/57">【有人遇到过重新拨号时没设置默认路由吗？】</a>这个 Issue 下面找到了问题原因。</p>

<p>主要问题在于 <code class="language-plaintext highlighter-rouge">WAN_6</code> 接口关闭晚于重新拨号建立的 <code class="language-plaintext highlighter-rouge">pppoe-wan</code> 接口，这会导致重新重新打开的 <code class="language-plaintext highlighter-rouge">WAN_6</code> 无法再获取到地址了。
解决的方法也很简单，在重新拨号时判断 <code class="language-plaintext highlighter-rouge">WAN_6</code> 是否已经关闭，如果没有关闭则等待关闭后再重新拨号。</p>

<p>可以根据<a href="https://github.com/hanwckf/immortalwrt-mt798x/issues/57#issuecomment-1586964992">【这里】</a>的方案：</p>

<div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">TARGET_FILE</span><span class="o">=</span><span class="s2">"/etc/ppp/ip-down.d/check_wan6_down"</span>

<span class="nb">mkdir</span> /etc/ppp/ip-down.d
<span class="nb">touch</span> <span class="nv">$TARGET_FILE</span>

<span class="nb">cat</span> <span class="o">&lt;&lt;</span> <span class="sh">'</span><span class="no">EOF</span><span class="sh">' &gt; "</span><span class="nv">$TARGET_FILE</span><span class="sh">"
#!/bin/sh
while true; do
  if ubus -S list "network.interface.wan_6"; then
    # ubus call network.interface.wan_6 down
    echo "waiting wan_6 down..." &gt;&gt; /path/to/logfile
    sleep 1
  else
    break
  fi
done
</span><span class="no">EOF

</span><span class="nb">chmod</span> +x <span class="s2">"</span><span class="nv">$TARGET_FILE</span><span class="s2">"</span>

<span class="c"># add file to backup configuration</span>
<span class="nb">echo</span> <span class="nv">$TARGET_FILE</span> <span class="o">&gt;&gt;</span> /etc/sysupgrade.conf
</code></pre></div></div>

<h3 id="注dropbear-相关">注：DropBear 相关</h3>

<p>IPV6 地址获取可能比 Dropbear 启动慢，如果在以前在 <code class="language-plaintext highlighter-rouge">WAN_6</code> 上有绑定 SSH 接口，则可能在地址变化后无法访问
（DropBear 监听了错误的接口，必须手动重启服务已改变监听地址。</p>

<p>一个简单的解决方案：将绑定的 <code class="language-plaintext highlighter-rouge">Interface</code> 设置为 <strong><code class="language-plaintext highlighter-rouge">unspecified</code></strong> 即可，不设置接口 DropBear 便会监听 <code class="language-plaintext highlighter-rouge">::::&lt;your-port&gt;</code>.</p>

<h2 id="一些无效的设置少走弯路">一些无效的设置（少走弯路）</h2>

<p>包括：</p>

<ul>
  <li>在防火墙中添加放行规则：IPV6 IGMP</li>
  <li>在防火墙中添加放行规则：456 to this device</li>
  <li>使用 <code class="language-plaintext highlighter-rouge">WAN</code> 自动创建的 <code class="language-plaintext highlighter-rouge">WAN_6</code> 而不是 OpenWRT 自带的 <code class="language-plaintext highlighter-rouge">WAN6</code></li>
</ul>

<p>对于第一条，首先 IPV6 使用 MLD 取代了 IGMP，因此只要在防火墙中放行 MLD 即可，放行 IGMP 没有意义也没有道理。</p>

<p>对于第二条，DHCPv6 客户端使用 546/547 端口，开放 456 端口也没有任何道理。</p>

<p>对于最后一条，自动创建的 <code class="language-plaintext highlighter-rouge">WAN_6</code> 和自己配置的 <code class="language-plaintext highlighter-rouge">WAN6</code> 其实没有区别，只要正确配置两者的效果是相同的，
不过对于一般用户使用 <code class="language-plaintext highlighter-rouge">WAN_6</code> 虚拟接口确实更加简单方便，不过最终使用哪个都可行，与上面出现的问题没有关系。</p>

<!-- refs -->]]></content><author><name>Fries_I23</name></author><category term="network" /><category term="openwrt" /><category term="ipv6" /><category term="pppoe" /><summary type="html"><![CDATA[最近于家庭路由开通 IPV6 后，凌晨运营商断线重新分配 IP 时，有一定概率获取不到 IPV6 地址（IPV4 正常）。 通过大量的搜索与尝试最终解决该问题，故在这里简单记录一下，后续如有类似问题也可作参考。]]></summary></entry></feed>