<?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-08-09T02:52:41+00:00</updated><id>https://friesi23.icu/feed.xml</id><title type="html">薯条与123</title><subtitle>随便写写, 随便玩玩, 随便搞搞
</subtitle><entry><title type="html">From compareTo to native_natural_sort: Flutter String Sorting in Practice</title><link href="https://friesi23.icu/post/en/202608/native-natural-sort" rel="alternate" type="text/html" title="From compareTo to native_natural_sort: Flutter String Sorting in Practice" /><published>2026-08-08T06:00:00+00:00</published><updated>2026-08-08T06:00:00+00:00</updated><id>https://friesi23.icu/post/en/202608/native-natural-sort-en</id><content type="html" xml:base="https://friesi23.icu/post/en/202608/native-natural-sort"><![CDATA[<p>While maintaining <a href="https://github.com/FriesI23/mhabit">Table Habit</a>, I noticed the habit list wasn’t sorting properly — items were ordered by name,
yet Chinese habit names had no discernible order and numbers were all over the place. That made me revisit the default
sorting approach the project had been using for a long time, and it turned out that a seemingly simple “sort by name”
hides far more pitfalls than expected.</p>

<p>This article walks through the journey from discovering the problem, to attempts at fixing it, to researching solutions,
and finally to the implementation — hoping to provide some reference for developers facing similar issues.</p>

<p>I’d also love for you to try out and discuss the Flutter Package I wrapped: <a href="https://pub.dev/packages/native_natural_sort"><strong>native_natural_sort</strong></a>.</p>

<h2 id="1-the-problem">1. The Problem</h2>

<h3 id="11-first-version-default-compareto-sorting">1.1. First Version: Default compareTo Sorting</h3>

<p>mhabit supports several sorting modes, of which “sort by name” is one of the most commonly used.
The first version was dead simple — it used Dart’s built-in <code class="language-plaintext highlighter-rouge">String.compareTo</code>:</p>

<div class="language-dart highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">data</span><span class="o">.</span><span class="na">sort</span><span class="p">((</span><span class="n">a</span><span class="p">,</span> <span class="n">b</span><span class="p">)</span> <span class="o">=</span><span class="p">&gt;</span> <span class="n">a</span><span class="o">.</span><span class="na">name</span><span class="o">.</span><span class="na">compareTo</span><span class="p">(</span><span class="n">b</span><span class="o">.</span><span class="na">name</span><span class="p">));</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">compareTo</code> is essentially <strong>lexicographic ordering</strong> — it compares characters one by one by their Unicode code points.
For purely ASCII English it seems to work fine, but once mixed case, non-Latin characters, and numbers enter the picture,
problems start to appear.</p>

<h3 id="12-first-pitfall-case-sensitivity">1.2. First Pitfall: Case Sensitivity</h3>

<p>The first issue noticed was case sensitivity. <code class="language-plaintext highlighter-rouge">compareTo</code> sorts by code points, and uppercase <code class="language-plaintext highlighter-rouge">A</code> (U+0041)
has a smaller code point than lowercase <code class="language-plaintext highlighter-rouge">a</code> (U+0061), so all names starting with uppercase letters sort before lowercase ones:</p>

<table>
  <thead>
    <tr>
      <th>Input</th>
      <th><code class="language-plaintext highlighter-rouge">compareTo</code> Result</th>
      <th>Expected Result</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">apple, Banana</code></td>
      <td><code class="language-plaintext highlighter-rouge">Banana, apple</code></td>
      <td><code class="language-plaintext highlighter-rouge">apple, Banana</code></td>
    </tr>
  </tbody>
</table>

<p>An intuitive fix is to normalize to lowercase:</p>

<div class="language-dart highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">data</span><span class="o">.</span><span class="na">sort</span><span class="p">((</span><span class="n">a</span><span class="p">,</span> <span class="n">b</span><span class="p">)</span> <span class="o">=</span><span class="p">&gt;</span> <span class="n">a</span><span class="o">.</span><span class="na">name</span><span class="o">.</span><span class="na">toLowerCase</span><span class="p">()</span><span class="o">.</span><span class="na">compareTo</span><span class="p">(</span><span class="n">b</span><span class="o">.</span><span class="na">name</span><span class="o">.</span><span class="na">toLowerCase</span><span class="p">()));</span>
</code></pre></div></div>

<p>That solved the case issue. But more problems soon surfaced.</p>

<h3 id="13-second-pitfall-cjk-diacritics-and-numbers">1.3. Second Pitfall: CJK, Diacritics, and Numbers</h3>

<p><code class="language-plaintext highlighter-rouge">toLowerCase()</code> fixed case, but never touched the root cause — Unicode code point order ≠ the order humans expect:</p>

<table>
  <thead>
    <tr>
      <th>Scenario</th>
      <th>Input</th>
      <th><code class="language-plaintext highlighter-rouge">compareTo</code> Result</th>
      <th>Human Expectation</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Numbers</td>
      <td><code class="language-plaintext highlighter-rouge">file2, file10</code></td>
      <td><code class="language-plaintext highlighter-rouge">file10, file2</code></td>
      <td><code class="language-plaintext highlighter-rouge">file2, file10</code></td>
    </tr>
    <tr>
      <td>Diacritics</td>
      <td><code class="language-plaintext highlighter-rouge">árbol, amor, azul</code></td>
      <td><code class="language-plaintext highlighter-rouge">amor, azul, árbol</code></td>
      <td><code class="language-plaintext highlighter-rouge">amor, árbol, azul</code></td>
    </tr>
    <tr>
      <td>Chinese (pinyin)</td>
      <td><code class="language-plaintext highlighter-rouge">张, 李, 王</code></td>
      <td><code class="language-plaintext highlighter-rouge">张, 李, 王</code></td>
      <td><code class="language-plaintext highlighter-rouge">李, 王, 张</code></td>
    </tr>
    <tr>
      <td>German ß</td>
      <td><code class="language-plaintext highlighter-rouge">Straße, Strasse</code></td>
      <td><code class="language-plaintext highlighter-rouge">Strasse, Straße</code></td>
      <td>Equal (ß = ss)</td>
    </tr>
  </tbody>
</table>

<p>The number issue: the first character <code class="language-plaintext highlighter-rouge">"1"</code> of <code class="language-plaintext highlighter-rouge">"10"</code> has a smaller code point than <code class="language-plaintext highlighter-rouge">"2"</code>, so <code class="language-plaintext highlighter-rouge">"10"</code> sorts before <code class="language-plaintext highlighter-rouge">"2"</code>.
The diacritic issue: <code class="language-plaintext highlighter-rouge">á</code> (U+00E1) has a code point far greater than <code class="language-plaintext highlighter-rouge">z</code> (U+007A), so <code class="language-plaintext highlighter-rouge">árbol</code> ends up at the end.
The Chinese issue is more complex — <code class="language-plaintext highlighter-rouge">compareTo</code> sorts by Unicode code point, and within the CJK Unified Ideographs block,
the order of <code class="language-plaintext highlighter-rouge">张</code> (U+5F20), <code class="language-plaintext highlighter-rouge">李</code> (U+674E), <code class="language-plaintext highlighter-rouge">王</code> (U+738B) has nothing to do with pinyin <code class="language-plaintext highlighter-rouge">lǐ, wáng, zhāng</code>.</p>

<p>At this point it became clear: <strong>simple case normalization is far from enough — you need a sort that understands language rules</strong>.</p>

<h2 id="2-choosing-a-solution">2. Choosing a Solution</h2>

<p>Armed with the problems above, I started systematically researching viable solutions. In the end I narrowed it down to
five directions (plus one seemingly promising but ultimately impractical middle path, discussed separately after Solution A):</p>

<h3 id="21-solution-overview">2.1. Solution Overview</h3>

<table>
  <thead>
    <tr>
      <th>Solution</th>
      <th>Approach</th>
      <th style="text-align: center">Numeric Sort</th>
      <th style="text-align: center">Language-Aware</th>
      <th style="text-align: center">Bundle Size Impact</th>
      <th style="text-align: center">Bridging Method</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><a href="#solution-a">A. Hand-written pure Dart natural sort</a></td>
      <td>Regex split + collation table</td>
      <td style="text-align: center">✅</td>
      <td style="text-align: center">❌</td>
      <td style="text-align: center">None</td>
      <td style="text-align: center">Synchronous</td>
    </tr>
    <tr>
      <td><a href="#solution-b">B. <code class="language-plaintext highlighter-rouge">collection.compareNatural</code></a></td>
      <td>Official Dart collection package</td>
      <td style="text-align: center">✅</td>
      <td style="text-align: center">❌</td>
      <td style="text-align: center">Negligible</td>
      <td style="text-align: center">Synchronous</td>
    </tr>
    <tr>
      <td><a href="#solution-c">C. Bundling traditional ICU data</a></td>
      <td>FFI linking to ICU library</td>
      <td style="text-align: center">✅</td>
      <td style="text-align: center">✅</td>
      <td style="text-align: center">+10~30MB</td>
      <td style="text-align: center">Synchronous (FFI)</td>
    </tr>
    <tr>
      <td><a href="#solution-d">D. <code class="language-plaintext highlighter-rouge">icu4x</code> + <code class="language-plaintext highlighter-rouge">intl4x</code></a></td>
      <td>Rust ICU4X + Dart FFI wrapper</td>
      <td style="text-align: center">✅</td>
      <td style="text-align: center">✅</td>
      <td style="text-align: center"><strong>Significant</strong></td>
      <td style="text-align: center">Synchronous (FFI)</td>
    </tr>
    <tr>
      <td><a href="#solution-e">E. Calling the platform-native engine</a></td>
      <td>Delegate to OS built-in APIs</td>
      <td style="text-align: center">✅</td>
      <td style="text-align: center">✅</td>
      <td style="text-align: center">Zero</td>
      <td style="text-align: center">FFI + Platform Method hybrid</td>
    </tr>
  </tbody>
</table>

<h3 id="solution-a">2.2. Solution A: Hand-written Pure Dart Natural Sort</h3>

<p>The most naive idea — implement it yourself. Split the string into text chunks and number chunks with a regex,
compare text chunks with <code class="language-plaintext highlighter-rouge">compareTo</code>, and compare number chunks numerically.</p>

<p><strong>Pros</strong>: zero dependencies, fully controllable.</p>

<p><strong>Cons</strong>:</p>

<ul>
  <li>Only solves numeric sorting; cannot handle locale-aware comparison (CJK, diacritics)</li>
  <li>Implementing the full Unicode Collation Algorithm (UCA)<sup>[1]</sup> is a huge undertaking — UCA is a complete
Unicode Technical Standard (UTS #10) that ICU has spent decades polishing</li>
  <li>Sorting rules differ greatly between languages (German ß=ss, Swedish å after z, Chinese by pinyin/strokes),
and you’d have to maintain a rule set for every language you add</li>
</ul>

<p><strong>Conclusion</strong>: workable for simple cases, but impractical for a multi-language app like this.</p>

<h3 id="23-a-seemingly-great-middle-path-transliterate-to-pinyin-then-sort">2.3. A Seemingly Great Middle Path: Transliterate to Pinyin Then Sort</h3>

<p>During research, one idea was seriously considered: <strong>transliterate non-Latin scripts to Latin letters first, then sort</strong>.
For example, Chinese to pinyin (<code class="language-plaintext highlighter-rouge">张</code>→<code class="language-plaintext highlighter-rouge">zhang</code>), Japanese to romanization (<code class="language-plaintext highlighter-rouge">東京</code>→<code class="language-plaintext highlighter-rouge">toukyou</code>),
Arabic to Latin transliteration (<code class="language-plaintext highlighter-rouge">سلام</code>→<code class="language-plaintext highlighter-rouge">salaam</code>), then sort the transliterated text with <code class="language-plaintext highlighter-rouge">compareTo</code>.</p>

<p>The appeal: pure Dart, zero platform dependencies, simple logic.
pub.dev also has ready-made pinyin packages (e.g., <a href="https://pub.dev/packages/pinyin"><code class="language-plaintext highlighter-rouge">pinyin</code></a>, <a href="https://pub.dev/packages/lpinyin"><code class="language-plaintext highlighter-rouge">lpinyin</code></a>),
so it looked like a simple <code class="language-plaintext highlighter-rouge">char→pinyin→compareTo</code> three-step solution.</p>

<p>But on closer analysis, this path has a few inescapable pitfalls.</p>

<h4 id="231-arabic-transliteration-loses-the-sorting-tradition">2.3.1. Arabic: Transliteration Loses the Sorting Tradition</h4>

<p>Arabic letters have their own independent sorting traditions. According to Wikipedia<sup>[2]</sup>,
there are two main Arabic orders: the traditional Abjadi order and the hijāʾī order used by modern dictionaries —
both completely different from the Latin <code class="language-plaintext highlighter-rouge">a b c d...</code> sequence. Transliterating Arabic letters into Latin
(<code class="language-plaintext highlighter-rouge">ا</code>→<code class="language-plaintext highlighter-rouge">a</code>, <code class="language-plaintext highlighter-rouge">ب</code>→<code class="language-plaintext highlighter-rouge">b</code>) and sorting by the Latin order yields results that don’t match what Arabic speakers recognize as correct.</p>

<p>In other words, transliteration-based sorting for Arabic isn’t just “imprecise” — it fundamentally applies the wrong rules.</p>

<h4 id="232-chinese-the-maintenance-nightmare-of-hardcoded-dictionaries">2.3.2. Chinese: The Maintenance Nightmare of Hardcoded Dictionaries</h4>

<p>The core problem with Chinese-to-pinyin isn’t the transliteration logic itself, but the <strong>data it depends on</strong>.</p>

<p>Existing pinyin packages (<code class="language-plaintext highlighter-rouge">pinyin</code>, <code class="language-plaintext highlighter-rouge">lpinyin</code>, etc.) work by hardcoding the character→pinyin mapping
from Unicode/Unihan into Dart source files.
<a href="https://pub.dev/packages/pinyin"><code class="language-plaintext highlighter-rouge">pinyin</code></a> was last released 2 years ago, with dictionary data from Unihan 15.0.0;
<a href="https://pub.dev/packages/lpinyin"><code class="language-plaintext highlighter-rouge">lpinyin</code></a> hasn’t been updated for <strong>5 years</strong>. Unihan gets a new release every year — new characters,
fixed readings, additional context for polyphonic characters — but these dictionaries are frozen in time.
New Unicode characters entered by users may fail to transliterate, and officially corrected readings never get synced.</p>

<h4 id="233-summary">2.3.3. Summary</h4>

<p>Transliterate-then-sort looks like a shortcut, but because the process <strong>discards the original language’s sorting semantics</strong>,
it produces wrong results for writing systems with independent sorting traditions (such as Arabic’s Abjadi/hijāʾī order).
Combined with the poor maintenance status of pinyin packages on pub.dev, this path is a dead end.</p>

<h3 id="solution-b">2.4. Solution B: <code class="language-plaintext highlighter-rouge">collection.compareNatural</code></h3>

<p>Dart’s official <a href="https://pub.dev/packages/collection"><code class="language-plaintext highlighter-rouge">collection</code></a> package provides a <code class="language-plaintext highlighter-rouge">compareNatural</code> function specifically for natural sorting.
It’s also listed among the implementations in the Wikipedia “Natural sort order” article<sup>[3]</sup>.</p>

<p><strong>Pros</strong>: maintained by the Dart team, zero additional dependencies (<code class="language-plaintext highlighter-rouge">collection</code> is already a transitive dependency of Flutter),
and numeric sorting is correct.</p>

<p><strong>Cons</strong>: <code class="language-plaintext highlighter-rouge">compareNatural</code> only solves numeric sorting; it does <strong>not</strong> support locale awareness.
Chinese still sorts by Unicode code point, and diacritics are still unhandled. Essentially it’s just “a better Solution A”.</p>

<p><strong>Conclusion</strong>: a reasonable fallback, but not the final answer.</p>

<h3 id="solution-c">2.5. Solution C: Bundling Traditional ICU Data</h3>

<p>ICU (International Components for Unicode) is the Unicode Consortium’s official i18n library,
with a complete collation implementation. Some Dart packages link ICU via FFI and bundle collation data into the app.</p>

<p><strong>Pros</strong>: complete functionality, correct sorting results.</p>

<p><strong>Cons</strong>:</p>

<ul>
  <li>ICU collation data is roughly 10~30MB — a significant size cost for mobile apps</li>
  <li>Requires compiling native libraries, complicating cross-platform builds</li>
  <li>The collation data version is fixed and won’t improve automatically as the OS updates</li>
</ul>

<p><strong>Conclusion</strong>: powerful but too heavy — better suited for scenarios needing full control over ICU.</p>

<h3 id="solution-d">2.6. Solution D: <code class="language-plaintext highlighter-rouge">icu4x</code> + <code class="language-plaintext highlighter-rouge">intl4x</code></h3>

<p><a href="https://github.com/unicode-org/icu4x">ICU4X</a> is the Unicode Consortium’s next-generation i18n library rewritten in Rust for resource-constrained
environments, designed around “small and modular code” and “pluggable locale data”.
On the Dart side, the official <a href="https://github.com/dart-lang/i18n/tree/main/pkgs/intl4x"><code class="language-plaintext highlighter-rouge">intl4x</code></a> package provides an FFI wrapper that already supports collation.</p>

<p>This is the closest thing to an “official solution” and deserves careful analysis.</p>

<p>See <a href="https://github.com/dart-lang/i18n/blob/main/pkgs/intl4x/lib/collation.dart">intl4x/lib/collation.dart</a>:</p>

<div class="language-dart highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">final</span> <span class="n">list</span> <span class="o">=</span> <span class="p">[</span><span class="s">'Z'</span><span class="p">,</span> <span class="s">'a'</span><span class="p">,</span> <span class="s">'z'</span><span class="p">,</span> <span class="s">'ä'</span><span class="p">];</span>
<span class="kd">final</span> <span class="n">german</span> <span class="o">=</span> <span class="n">Collation</span><span class="p">(</span><span class="nl">locale:</span> <span class="n">Locale</span><span class="o">.</span><span class="na">parse</span><span class="p">(</span><span class="s">'de-DE'</span><span class="p">));</span>
<span class="n">list</span><span class="o">.</span><span class="na">sort</span><span class="p">(</span><span class="n">german</span><span class="o">.</span><span class="na">compare</span><span class="p">);</span>
<span class="n">print</span><span class="p">(</span><span class="n">list</span><span class="p">);</span> <span class="c1">// [a, ä, z, Z]</span>
</code></pre></div></div>

<p><strong>Pros</strong>:</p>

<ul>
  <li>Maintained by the Dart team (<code class="language-plaintext highlighter-rouge">labs.dart.dev</code>), with long-term support assurance</li>
  <li>Complete functionality: collation + datetime + number + plural + display names</li>
  <li>Built on ICU4X, architecturally more modular than traditional ICU</li>
  <li>Cross-platform support</li>
</ul>

<p><strong>Cons</strong>:</p>

<ul>
  <li><strong>Significantly larger binary size</strong>: the README explicitly states “This package currently increases binary size
in non-web builds significantly”, mitigable only with the experimental <code class="language-plaintext highlighter-rouge">--enable-experiment=record-use</code> flag</li>
  <li><strong>Flathub builds need extra preprocessing</strong>: <code class="language-plaintext highlighter-rouge">intl4x</code> depends on the <code class="language-plaintext highlighter-rouge">icu4x</code> FFI native library, which requires compiling
Rust ICU4X → <code class="language-plaintext highlighter-rouge">.so</code>/<code class="language-plaintext highlighter-rouge">.dylib</code>/<code class="language-plaintext highlighter-rouge">.dll</code>. The Flathub build sandbox is fully offline and cannot run <code class="language-plaintext highlighter-rouge">cargo build</code>
to download dependencies — exactly the pitfall encountered in <a href="/post/202507/flutter-flatpak-and-flathub/">the previous Flathub post</a>.
There are two workarounds: pre-compile each platform’s binary and commit it into the source tree (as recommended by the official flow),
or manually add a Rust vendoring + offline compilation module to the flatpak-builder manifest.
Either way, Flatpak packaging maintenance complexity increases significantly.</li>
  <li><strong>Experimental stage</strong>: the current stable version is 0.17.0, the API is still iterating,
and the <code class="language-plaintext highlighter-rouge">icu4x</code> Dart binding is only at 0.0.1 stable — it actually depends on a pre-release (2.2.0-dev.3)</li>
  <li><strong>Heavy dependency</strong>: just for collation, you’d pull in the entire i18n framework
(datetime, number, plural, case mapping, etc.) — clear over-engineering for a use case that only needs string sorting</li>
</ul>

<p><strong>Conclusion</strong>: most powerful and officially endorsed, but too heavy, costly to maintain for Flathub packaging,
and — as of 2026-08 — not yet in a stable release, so using it carries significant risk.</p>

<h3 id="solution-e">2.7. Solution E: Calling the Platform-Native Sorting Engine</h3>

<p>Every operating system already ships a mature sorting engine — Android has <code class="language-plaintext highlighter-rouge">android.icu.text.Collator</code>,
iOS/macOS have <code class="language-plaintext highlighter-rouge">localizedStandardCompare:</code>, Linux has <code class="language-plaintext highlighter-rouge">libicui18n</code>,
Windows has <code class="language-plaintext highlighter-rouge">CompareStringEx</code>, and the Web has <code class="language-plaintext highlighter-rouge">Intl.Collator</code><sup>[4]</sup>.</p>

<p>These APIs have been silently working in the background — Finder sorts files by name, Android file managers sort files,
Windows Explorer sorts files — all thanks to them. So why not call them directly?</p>

<p>This solution takes a <strong>hybrid route</strong>: desktop platforms prefer FFI (synchronous) to call system libraries directly,
while mobile platforms bridge native APIs via Platform Methods (asynchronous), uniformly exposing a <code class="language-plaintext highlighter-rouge">Future</code> interface.
Linux goes through FFI→<code class="language-plaintext highlighter-rouge">libicui18n</code>, Windows uses FFI→ICU DLL primarily with Pigeon→<code class="language-plaintext highlighter-rouge">CompareStringEx</code> as a fallback,
and Android/iOS/macOS — whose docs don’t expose FFI-friendly public collation entry points —
use platform bridging for maintainability reasons.</p>

<p><strong>Pros</strong>:</p>

<ul>
  <li><strong>Zero additional size</strong>: no collation data bundled; calls the OS built-in API directly</li>
  <li><strong>System consistency</strong>: sorting results match the system file manager exactly</li>
  <li><strong>Automatic updates</strong>: collation data improves as the OS updates, no package version bump needed</li>
  <li><strong>Flathub-friendly</strong>: no extra downloads/compilation; pure Dart + Pigeon/FFI calls to system APIs</li>
  <li><strong>All platforms</strong>: every major Flutter platform has a mature system-level collation API</li>
</ul>

<p><strong>Cons</strong>:</p>

<ul>
  <li>Requires writing different bridging code per platform (FFI / Platform Method)</li>
  <li><strong>Some platforms require async calls</strong>: Android/iOS/macOS go through platform channels (async),
meaning you can’t directly pass the comparator to <code class="language-plaintext highlighter-rouge">List.sort()</code>.
That said, Linux (FFI→ICU) and Windows (FFI→ICU DLL) are synchronous under the hood —
for API consistency, the package uniformly returns <code class="language-plaintext highlighter-rouge">Future</code>, and callers handle it asynchronously</li>
  <li>Platform API behaviors may differ subtly</li>
  <li>Unified error handling and graceful fallback are needed on the Dart side</li>
</ul>

<p><strong>Conclusion</strong>: lightweight, Flathub-compatible, and small in size — a great balance for apps that don’t need
absolute control over multi-platform consistency but want quick access to natural-language sorting
or the closest possible consistency with the system experience.</p>

<h2 id="3-implementation">3. Implementation</h2>

<h3 id="31-phase-one-platform-wrappers-inside-mhabit">3.1. Phase One: Platform Wrappers Inside mhabit</h3>

<p>Once Solution E was chosen, the first thing wasn’t to create a standalone package immediately —
it was to get each platform’s sorting call working inside the app first, validating the feasibility of the approach.</p>

<p>The core idea:</p>

<ul>
  <li>Define a unified sorting interface on the Dart side (<code class="language-plaintext highlighter-rouge">SortItem</code> + <code class="language-plaintext highlighter-rouge">NativeSort.sort()</code>)</li>
  <li>Desktop calls system collation libraries directly via Dart FFI
    <ul>
      <li>Linux→<code class="language-plaintext highlighter-rouge">libicui18n</code></li>
      <li>Windows→ICU DLL, also providing a Pigeon fallback (<code class="language-plaintext highlighter-rouge">CompareStringEx</code>)</li>
    </ul>
  </li>
  <li>Mobile bridges native APIs via Pigeon (Android/iOS/macOS)</li>
</ul>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>NativeSort (Dart API)
├── Android   → Pigeon → android.icu.text.Collator
├── iOS       → Pigeon → localizedStandardCompare:
├── macOS     → Pigeon → localizedStandardCompare:
├── Linux     → Dart FFI → system libicui18n
├── Windows   → Pigeon (CompareStringEx) or FFI (ICU)
└── Web       → Dart → Intl.Collator
</code></pre></div></div>

<h3 id="32-phase-two-extracting-a-standalone-package">3.2. Phase Two: Extracting a Standalone Package</h3>

<p>After getting it working inside the project, I found three reasons this code deserved to be published as a package:</p>

<ol>
  <li>
    <p><strong>pub.dev has no similar all-platform native sorting solution</strong>. <code class="language-plaintext highlighter-rouge">collection.compareNatural</code> only solves numeric sorting,
and <code class="language-plaintext highlighter-rouge">intl4x</code> is too heavy and experimental — in the “lightweight, all-platform, native sorting” niche, there’s a clear gap.</p>
  </li>
  <li>
    <p><strong>Flathub compatibility is a hard requirement</strong>. mhabit is already published on Flathub, and any dependency
requiring extra downloads/compilation is unacceptable. <code class="language-plaintext highlighter-rouge">intl4x</code>/<code class="language-plaintext highlighter-rouge">icu4x</code> exactly fit that description —
they need extra FFI compilation, requiring patches or pre-compilation, which is annoying and fragile.
We’ve already been burned by this with the <code class="language-plaintext highlighter-rouge">sqlite3</code> package.</p>
  </li>
  <li>
    <p><strong>Decoupling and reuse</strong>. Sorting logic is business-agnostic; extracted, it can be tested independently,
released independently, and referenced by other Flutter projects.</p>
  </li>
</ol>

<p>And so <a href="https://pub.dev/packages/native_natural_sort"><strong>native_natural_sort</strong></a> came to be.</p>

<h3 id="33-integrating-into-mhabit">3.3. Integrating into mhabit</h3>

<p>The mhabit integration changes are concentrated in a few files.</p>

<p>See <a href="https://github.com/FriesI23/mhabit/blob/main/lib/main.dart">lib/main.dart</a>:</p>

<div class="language-dart highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">await</span> <span class="nf">NativeSort</span><span class="p">()</span><span class="o">.</span><span class="na">config</span><span class="p">(</span>
  <span class="n">SortOptions</span><span class="p">(</span>
    <span class="nl">windows:</span> <span class="n">SortOptionsWindows</span><span class="o">.</span><span class="na">ffi</span><span class="p">(</span><span class="nl">onLoadFailed:</span> <span class="n">WindowsSort</span><span class="o">.</span><span class="na">pigeon</span><span class="p">),</span>
  <span class="p">),</span>
<span class="p">);</span>
</code></pre></div></div>

<p>A one-time initialization in <code class="language-plaintext highlighter-rouge">main()</code>. On Windows, it configures a dual-backend fallback of FFI (ICU) → Pigeon (<code class="language-plaintext highlighter-rouge">CompareStringEx</code>).</p>

<p>See <a href="https://github.com/FriesI23/mhabit/blob/main/lib/extensions/collation_extensions.dart">lib/extensions/collation_extensions.dart</a>,
which wraps a generic <code class="language-plaintext highlighter-rouge">naturalSort&lt;T&gt;</code> extension method that accepts <code class="language-plaintext highlighter-rouge">idOf</code>/<code class="language-plaintext highlighter-rouge">valueOf</code> functions to adapt to arbitrary data types:</p>

<div class="language-dart highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">extension</span> <span class="n">NaturalSortExtension</span> <span class="kd">on</span> <span class="n">NativeSort</span> <span class="p">{</span>
  <span class="n">Future</span><span class="p">&lt;</span><span class="kt">List</span><span class="p">&lt;</span><span class="n">T</span><span class="p">&gt;&gt;</span> <span class="n">naturalSort</span><span class="p">&lt;</span><span class="n">T</span><span class="p">&gt;({</span>
    <span class="kd">required</span> <span class="kt">List</span><span class="p">&lt;</span><span class="n">T</span><span class="p">&gt;</span> <span class="n">items</span><span class="p">,</span>
    <span class="kd">required</span> <span class="kt">String</span> <span class="kt">Function</span><span class="p">(</span><span class="n">T</span><span class="p">)</span> <span class="n">idOf</span><span class="p">,</span>
    <span class="kd">required</span> <span class="kt">String</span> <span class="kt">Function</span><span class="p">(</span><span class="n">T</span><span class="p">)</span> <span class="n">valueOf</span><span class="p">,</span>
    <span class="kt">bool</span> <span class="n">descending</span> <span class="o">=</span> <span class="kc">false</span><span class="p">,</span>
    <span class="kt">String</span><span class="o">?</span> <span class="n">locale</span><span class="p">,</span>
  <span class="p">})</span> <span class="kd">async</span> <span class="p">{</span>
    <span class="k">if</span> <span class="p">(</span><span class="n">items</span><span class="o">.</span><span class="na">isEmpty</span><span class="p">)</span> <span class="k">return</span> <span class="n">items</span><span class="p">;</span>
    <span class="kd">final</span> <span class="n">sortItems</span> <span class="o">=</span> <span class="n">items</span>
        <span class="o">.</span><span class="na">map</span><span class="p">((</span><span class="n">e</span><span class="p">)</span> <span class="o">=</span><span class="p">&gt;</span> <span class="n">SortItem</span><span class="p">(</span><span class="nl">id:</span> <span class="n">idOf</span><span class="p">(</span><span class="n">e</span><span class="p">),</span> <span class="nl">value:</span> <span class="n">valueOf</span><span class="p">(</span><span class="n">e</span><span class="p">)))</span>
        <span class="o">.</span><span class="na">toList</span><span class="p">();</span>
    <span class="kd">final</span> <span class="n">sorted</span> <span class="o">=</span> <span class="k">await</span> <span class="n">sort</span><span class="p">(</span><span class="n">sortItems</span><span class="p">,</span> <span class="cm">/* ... */</span><span class="p">);</span>
    <span class="c1">// Map sorted IDs back to original items.</span>
    <span class="c1">// ...</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>At the ViewModel layer, the sorting logic goes through a <code class="language-plaintext highlighter-rouge">SortGuard</code> with graceful fallback:</p>

<ul>
  <li>When natural sort is enabled and the sort mode is “by name”, take the async native sorting path</li>
  <li>If the async sort fails (e.g., a platform API exception), automatically fall back to the default <code class="language-plaintext highlighter-rouge">compareTo</code> sort so functionality never crashes</li>
  <li><code class="language-plaintext highlighter-rouge">SortGuard</code> ensures that only the latest result survives concurrent re-sort requests</li>
</ul>

<p>See <code class="language-plaintext highlighter-rouge">_ResortHandler</code> in <a href="https://github.com/FriesI23/mhabit/blob/main/lib/pages/habits_display/_providers/habit_summary.dart">lib/pages/habits_display/_providers/habit_summary.dart</a>:</p>

<div class="language-dart highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">class</span> <span class="nc">_ResortHandler</span> <span class="p">{</span>
  <span class="n">FutureOr</span><span class="p">&lt;</span><span class="n">_HabitsSortableCache</span><span class="p">&gt;</span> <span class="n">call</span><span class="p">()</span> <span class="p">{</span>
    <span class="kd">final</span> <span class="n">needHabitNatural</span> <span class="o">=</span>
        <span class="n">cache</span><span class="o">.</span><span class="na">sortType</span> <span class="o">==</span> <span class="n">HabitDisplaySortType</span><span class="o">.</span><span class="na">name</span> <span class="o">&amp;&amp;</span>
        <span class="n">_vm</span><span class="o">.</span><span class="na">_naturalSortEnabled</span><span class="p">;</span>
    <span class="kd">final</span> <span class="n">needGroupNatural</span> <span class="o">=</span> <span class="cm">/* ... */</span><span class="p">;</span>

    <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="n">needHabitNatural</span> <span class="o">&amp;&amp;</span> <span class="o">!</span><span class="n">needGroupNatural</span><span class="p">)</span> <span class="k">return</span> <span class="n">defaultSort</span><span class="p">();</span>
    <span class="k">return</span> <span class="n">_guardedNaturalSort</span><span class="p">(</span><span class="n">needHabitNatural</span><span class="p">,</span> <span class="n">needGroupNatural</span><span class="p">);</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>It also exposes a <code class="language-plaintext highlighter-rouge">NaturalSortExperimentalFeature</code> toggle so users can choose whether to enable native sorting (on by default).</p>

<h3 id="34-async-refactor-the-sortguard-pattern">3.4. Async Refactor: The SortGuard Pattern</h3>

<p>The hybrid route of Solution E brings a practical engineering problem:
FFI sorting on Linux/Windows is synchronous under the hood, but Pigeon sorting on Android/iOS/macOS is asynchronous.
For API consistency, <code class="language-plaintext highlighter-rouge">native_natural_sort</code> uniformly returns <code class="language-plaintext highlighter-rouge">Future</code>.
This means the original synchronous sorting logic — which all Solutions A~D could do with a direct <code class="language-plaintext highlighter-rouge">list.sort(compareFn)</code> — needed an async refactor.</p>

<p>mhabit’s approach is to introduce <code class="language-plaintext highlighter-rouge">SortGuard</code> — a simple counter-based sort guard. The core idea:</p>

<ul>
  <li>Call <code class="language-plaintext highlighter-rouge">_sortGuard.bump()</code> before each data reload, incrementing the generation</li>
  <li>Wrap the sort with <code class="language-plaintext highlighter-rouge">_sortGuard.run()</code>, capturing the current generation</li>
  <li>After the async sort completes, compare generations — if they differ, the data has changed and the result is invalid</li>
</ul>

<p>See <a href="https://github.com/FriesI23/mhabit/blob/main/lib/common/sort_generation.dart">lib/common/sort_generation.dart</a>:</p>

<div class="language-dart highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">class</span> <span class="nc">SortGuard</span> <span class="p">{</span>
  <span class="kt">int</span> <span class="n">_generation</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span>

  <span class="kt">void</span> <span class="n">bump</span><span class="p">()</span> <span class="p">{</span> <span class="n">_generation</span><span class="o">++</span><span class="p">;</span> <span class="p">}</span>

  <span class="n">FutureOr</span><span class="p">&lt;</span><span class="n">T</span><span class="o">?</span><span class="p">&gt;</span> <span class="n">run</span><span class="p">&lt;</span><span class="n">T</span><span class="p">&gt;(</span><span class="n">FutureOr</span><span class="p">&lt;</span><span class="n">T</span><span class="p">&gt;</span> <span class="kt">Function</span><span class="p">()</span> <span class="n">sort</span><span class="p">)</span> <span class="p">{</span>
    <span class="kd">final</span> <span class="n">token</span> <span class="o">=</span> <span class="n">_generation</span><span class="p">;</span>
    <span class="kd">final</span> <span class="n">result</span> <span class="o">=</span> <span class="n">sort</span><span class="p">();</span>
    <span class="k">return</span> <span class="n">result</span> <span class="k">is</span> <span class="n">Future</span><span class="p">&lt;</span><span class="n">T</span><span class="p">&gt;</span>
        <span class="o">?</span> <span class="n">result</span><span class="o">.</span><span class="na">then</span><span class="p">((</span><span class="n">r</span><span class="p">)</span> <span class="o">=</span><span class="p">&gt;</span> <span class="n">_generation</span> <span class="o">==</span> <span class="n">token</span> <span class="o">?</span> <span class="n">r</span> <span class="o">:</span> <span class="kc">null</span><span class="p">)</span>
        <span class="o">:</span> <span class="p">(</span><span class="n">_generation</span> <span class="o">==</span> <span class="n">token</span> <span class="o">?</span> <span class="n">result</span> <span class="o">:</span> <span class="kc">null</span><span class="p">);</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>In the ViewModel, <code class="language-plaintext highlighter-rouge">_resortData()</code>’s return type changed from <code class="language-plaintext highlighter-rouge">void</code> to <code class="language-plaintext highlighter-rouge">FutureOr&lt;void&gt;</code> —
the synchronous path (default sort) returns directly, the async path (native sort) returns a <code class="language-plaintext highlighter-rouge">Future</code>,
and callers handle both uniformly with <code class="language-plaintext highlighter-rouge">await _resortData()</code>, keeping the interface unchanged.</p>

<p>Combined with a <code class="language-plaintext highlighter-rouge">.catchError((e) =&gt; defaultSort())</code> graceful fallback,
the async refactor is completely transparent to upper layers.</p>

<h3 id="35-the-final-result">3.5. The Final Result</h3>

<p>After integrating <a href="https://pub.dev/packages/native_natural_sort"><code class="language-plaintext highlighter-rouge">native_natural_sort</code></a>, the “sort by name” results compared:</p>

<table>
  <thead>
    <tr>
      <th>Input</th>
      <th>Dart <code class="language-plaintext highlighter-rouge">compareTo</code></th>
      <th><code class="language-plaintext highlighter-rouge">native_natural_sort</code></th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">item2, item10</code></td>
      <td><code class="language-plaintext highlighter-rouge">item10, item2</code></td>
      <td><code class="language-plaintext highlighter-rouge">item2, item10</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">árbol, amor, azul</code></td>
      <td><code class="language-plaintext highlighter-rouge">amor, azul, árbol</code></td>
      <td><code class="language-plaintext highlighter-rouge">amor, árbol, azul</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">张, 李, 王</code></td>
      <td><code class="language-plaintext highlighter-rouge">张, 李, 王</code></td>
      <td><code class="language-plaintext highlighter-rouge">李, 王, 张</code> (pinyin)</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Straße, Strasse</code></td>
      <td><code class="language-plaintext highlighter-rouge">Strasse, Straße</code></td>
      <td>Equal (German ß = ss)</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">v1, v2, v10</code></td>
      <td><code class="language-plaintext highlighter-rouge">v1, v10, v2</code></td>
      <td><code class="language-plaintext highlighter-rouge">v1, v2, v10</code></td>
    </tr>
  </tbody>
</table>

<p>All the problems mentioned earlier are now well resolved.</p>

<h2 id="4-summary">4. Summary</h2>

<p>Looking back, the core lesson is: <strong>string sorting looks simple, but once you have multi-language users, don’t reinvent the wheel</strong>.</p>

<p>The Unicode Collation Algorithm (UCA) is a standard that has evolved over decades, and every OS already ships
a battle-tested implementation. Instead of bundling ICU data or relying on experimental frameworks,
calling the OS-native API directly is the more pragmatic choice — zero size growth (no ICU database to maintain),
system-level correctness, and automatic improvement as the OS updates.</p>

<p>The package is now published on <a href="https://pub.dev/packages/native_natural_sort">pub.dev/native_natural_sort</a>.
If you’re building a multi-language Flutter app and hitting similar issues, I hope this package helps.</p>

<h2 id="references">References</h2>

<ol>
  <li><a href="https://en.wikipedia.org/wiki/Unicode_collation_algorithm">Unicode Collation Algorithm (UCA)</a> — Wikipedia</li>
  <li><a href="https://en.wikipedia.org/wiki/Abjad_numerals">Abjad numerals</a> — Wikipedia</li>
  <li><a href="https://en.wikipedia.org/wiki/Natural_sort_order">Natural sort order</a> — Wikipedia</li>
  <li><a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Collator">Intl.Collator</a> — MDN Web Docs</li>
</ol>

<!-- refs -->]]></content><author><name>Fries_I23</name></author><category term="flutter" /><category term="flutter" /><category term="dart" /><category term="natural-sort" /><category term="native-natural-sort" /><category term="collation" /><summary type="html"><![CDATA[Dart's default compareTo causes a host of problems for human-friendly name sorting: case sensitivity, CJK characters, diacritics, and numeric ordering. This article documents the full journey — from discovering the problem, to researching five approaches, to finally developing the native_natural_sort package and integrating it into the mhabit project.]]></summary></entry><entry><title type="html">从 compareTo 到 native_natural_sort —— Flutter 字符串排序实践</title><link href="https://friesi23.icu/post/202608/native-natural-sort" rel="alternate" type="text/html" title="从 compareTo 到 native_natural_sort —— Flutter 字符串排序实践" /><published>2026-08-08T06:00:00+00:00</published><updated>2026-08-08T06:00:00+00:00</updated><id>https://friesi23.icu/post/202608/native-natural-sort</id><content type="html" xml:base="https://friesi23.icu/post/202608/native-natural-sort"><![CDATA[<p>最近在维护 <a href="https://github.com/FriesI23/mhabit">Table Habit</a> 时，发现习惯列表的排序不太对劲——明明是按名称排序，
但中文习惯名的顺序毫无规律，数字也排得乱七八糟。这让我开始重新审视项目中用了很久的默认排序方式，
结果发现，一个看似简单的「按名称排序」，背后的坑远比想象的多。</p>

<p>本文将从问题的发现开始，依次介绍修复尝试、方案调研以及最终的实现方案，希望能为遇到类似问题的开发者提供一些参考。</p>

<p>同时，也欢迎体验和交流我封装的 Flutter Package: <a href="https://pub.dev/packages/native_natural_sort"><strong>native_natural_sort</strong></a>。</p>

<h2 id="1-问题">1. 问题</h2>

<h3 id="11-第一版compareto-默认排序">1.1. 第一版：compareTo 默认排序</h3>

<p>mhabit 支持多种排序方式，其中「按名称排序」是最常用的之一。
第一版实现非常简单，就是 Dart 内置的 <code class="language-plaintext highlighter-rouge">String.compareTo</code>：</p>

<div class="language-dart highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">data</span><span class="o">.</span><span class="na">sort</span><span class="p">((</span><span class="n">a</span><span class="p">,</span> <span class="n">b</span><span class="p">)</span> <span class="o">=</span><span class="p">&gt;</span> <span class="n">a</span><span class="o">.</span><span class="na">name</span><span class="o">.</span><span class="na">compareTo</span><span class="p">(</span><span class="n">b</span><span class="o">.</span><span class="na">name</span><span class="p">));</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">compareTo</code> 本质上是<strong>字典序</strong>——按 Unicode 码点逐字符比较。对于纯 ASCII 英文来说似乎工作正常，
但一旦涉及混合大小写、非英文字符、数字，问题就来了。</p>

<h3 id="12-第一个坑大小写">1.2. 第一个坑：大小写</h3>

<p>最早被注意到的是大小写问题。<code class="language-plaintext highlighter-rouge">compareTo</code> 按码点排序，大写字母 <code class="language-plaintext highlighter-rouge">A</code>（U+0041）的码点小于小写 <code class="language-plaintext highlighter-rouge">a</code>（U+0061），
导致所有大写开头的名称排在小写前面：</p>

<table>
  <thead>
    <tr>
      <th>输入</th>
      <th><code class="language-plaintext highlighter-rouge">compareTo</code> 结果</th>
      <th>期望结果</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">apple, Banana</code></td>
      <td><code class="language-plaintext highlighter-rouge">Banana, apple</code></td>
      <td><code class="language-plaintext highlighter-rouge">apple, Banana</code></td>
    </tr>
  </tbody>
</table>

<p>一个直观的修复是统一转为小写：</p>

<div class="language-dart highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">data</span><span class="o">.</span><span class="na">sort</span><span class="p">((</span><span class="n">a</span><span class="p">,</span> <span class="n">b</span><span class="p">)</span> <span class="o">=</span><span class="p">&gt;</span> <span class="n">a</span><span class="o">.</span><span class="na">name</span><span class="o">.</span><span class="na">toLowerCase</span><span class="p">()</span><span class="o">.</span><span class="na">compareTo</span><span class="p">(</span><span class="n">b</span><span class="o">.</span><span class="na">name</span><span class="o">.</span><span class="na">toLowerCase</span><span class="p">()));</span>
</code></pre></div></div>

<p>大小写问题解决了。但很快更多的问题出现了。</p>

<h3 id="13-第二个坑cjk变音符号数字">1.3. 第二个坑：CJK、变音符号、数字</h3>

<p><code class="language-plaintext highlighter-rouge">toLowerCase()</code> 修好了大小写，但根本没有触及排序的根本问题——Unicode 码点顺序 ≠ 人类期望的排序顺序：</p>

<table>
  <thead>
    <tr>
      <th>场景</th>
      <th>输入</th>
      <th><code class="language-plaintext highlighter-rouge">compareTo</code> 结果</th>
      <th>人类期望</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>数字</td>
      <td><code class="language-plaintext highlighter-rouge">file2, file10</code></td>
      <td><code class="language-plaintext highlighter-rouge">file10, file2</code></td>
      <td><code class="language-plaintext highlighter-rouge">file2, file10</code></td>
    </tr>
    <tr>
      <td>变音符号</td>
      <td><code class="language-plaintext highlighter-rouge">árbol, amor, azul</code></td>
      <td><code class="language-plaintext highlighter-rouge">amor, azul, árbol</code></td>
      <td><code class="language-plaintext highlighter-rouge">amor, árbol, azul</code></td>
    </tr>
    <tr>
      <td>中文（拼音）</td>
      <td><code class="language-plaintext highlighter-rouge">张, 李, 王</code></td>
      <td><code class="language-plaintext highlighter-rouge">张, 李, 王</code></td>
      <td><code class="language-plaintext highlighter-rouge">李, 王, 张</code></td>
    </tr>
    <tr>
      <td>德语 ß</td>
      <td><code class="language-plaintext highlighter-rouge">Straße, Strasse</code></td>
      <td><code class="language-plaintext highlighter-rouge">Strasse, Straße</code></td>
      <td>相等（ß = ss）</td>
    </tr>
  </tbody>
</table>

<p>数字的问题在于 <code class="language-plaintext highlighter-rouge">"10"</code> 的第一个字符 <code class="language-plaintext highlighter-rouge">"1"</code> 码点小于 <code class="language-plaintext highlighter-rouge">"2"</code>，所以 <code class="language-plaintext highlighter-rouge">"10"</code> 排在 <code class="language-plaintext highlighter-rouge">"2"</code> 前面。
变音符号 <code class="language-plaintext highlighter-rouge">á</code>（U+00E1）的码点远大于 <code class="language-plaintext highlighter-rouge">z</code>（U+007A），所以 <code class="language-plaintext highlighter-rouge">árbol</code> 被排到了末尾。
中文的问题更复杂——<code class="language-plaintext highlighter-rouge">compareTo</code> 按 Unicode 码点排序，对于 CJK 统一表意文字区块，
<code class="language-plaintext highlighter-rouge">张</code>（U+5F20）、<code class="language-plaintext highlighter-rouge">李</code>（U+674E）、<code class="language-plaintext highlighter-rouge">王</code>（U+738B）的顺序与拼音 <code class="language-plaintext highlighter-rouge">lǐ, wáng, zhāng</code> 完全不同。</p>

<p>至此，必须承认：<strong>简单的大小写归一化远远不够，需要一种理解语言规则的排序方式</strong>。</p>

<h2 id="2-方案选择">2. 方案选择</h2>

<p>带着上面的问题，我开始系统性地调研可行的解决方案。最终梳理出五种方向（外加一种看似美好但实际不太可行的折中方案，
在方案 A 之后单独讨论）：</p>

<h3 id="21-方案概览">2.1. 方案概览</h3>

<table>
  <thead>
    <tr>
      <th>方案</th>
      <th>思路</th>
      <th style="text-align: center">数字排序</th>
      <th style="text-align: center">语言感知</th>
      <th style="text-align: center">包体积影响</th>
      <th style="text-align: center">桥接方式</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><a href="#solution-a">A. 手写纯 Dart 自然排序</a></td>
      <td>正则拆分数字 + collation 表</td>
      <td style="text-align: center">✅</td>
      <td style="text-align: center">❌</td>
      <td style="text-align: center">无</td>
      <td style="text-align: center">同步</td>
    </tr>
    <tr>
      <td><a href="#solution-b">B. <code class="language-plaintext highlighter-rouge">collection.compareNatural</code></a></td>
      <td>Dart 官方 collection 包</td>
      <td style="text-align: center">✅</td>
      <td style="text-align: center">❌</td>
      <td style="text-align: center">极小</td>
      <td style="text-align: center">同步</td>
    </tr>
    <tr>
      <td><a href="#solution-c">C. 打包传统 ICU 数据</a></td>
      <td>FFI 链接 ICU 库</td>
      <td style="text-align: center">✅</td>
      <td style="text-align: center">✅</td>
      <td style="text-align: center">+10~30MB</td>
      <td style="text-align: center">同步 (FFI)</td>
    </tr>
    <tr>
      <td><a href="#solution-d">D. <code class="language-plaintext highlighter-rouge">icu4x</code> + <code class="language-plaintext highlighter-rouge">intl4x</code></a></td>
      <td>Rust ICU4X + Dart FFI 封装</td>
      <td style="text-align: center">✅</td>
      <td style="text-align: center">✅</td>
      <td style="text-align: center"><strong>显著增加</strong></td>
      <td style="text-align: center">同步 (FFI)</td>
    </tr>
    <tr>
      <td><a href="#solution-e">E. 调用平台原生引擎</a></td>
      <td>委托 OS 内置 API</td>
      <td style="text-align: center">✅</td>
      <td style="text-align: center">✅</td>
      <td style="text-align: center">零</td>
      <td style="text-align: center">FFI + Platform Method 混合</td>
    </tr>
  </tbody>
</table>

<h3 id="solution-a">2.2. 方案 A：手写纯 Dart 自然排序</h3>

<p>最朴素的想法——自己实现。按正则将字符串拆分为文本块和数字块，
文本块用 <code class="language-plaintext highlighter-rouge">compareTo</code> 比较，数字块按数值比较。</p>

<p><strong>优点</strong>：零依赖，完全可控。</p>

<p><strong>缺点</strong>：</p>

<ul>
  <li>只能解决数字排序，无法处理 locale 感知的比较（CJK、变音符号）</li>
  <li>要实现完整的 Unicode Collation Algorithm (UCA)<sup>[1]</sup> 工作量巨大——UCA 是一个完整的 Unicode 技术标准（UTS #10），ICU 用了数十年才打磨成熟</li>
  <li>不同语言的排序规则差异很大（德语 ß=ss、瑞典语 å 在 z 之后、中文按拼音/笔画），每加一种语言就要维护一份规则</li>
</ul>

<p><strong>结论</strong>：对简单场景可行，但对这种多语言应用不实际。</p>

<h3 id="23-一个看似美好的折中路线转拼音再排序">2.3. 一个看似美好的折中路线：转拼音再排序</h3>

<p>在调研过程中，有一个思路曾经认真考虑过：<strong>先把非拉丁文字转写为拉丁字母，再排序</strong>。
比如中文转拼音（<code class="language-plaintext highlighter-rouge">张</code>→<code class="language-plaintext highlighter-rouge">zhang</code>）、日文转罗马字（<code class="language-plaintext highlighter-rouge">東京</code>→<code class="language-plaintext highlighter-rouge">toukyou</code>）、
阿拉伯文转拉丁转写（<code class="language-plaintext highlighter-rouge">سلام</code>→<code class="language-plaintext highlighter-rouge">salaam</code>），转写后用 <code class="language-plaintext highlighter-rouge">compareTo</code> 排序。</p>

<p>这个方案的诱惑力在于：纯 Dart 实现、零平台依赖、逻辑简单。
pub.dev 上也有现成的拼音转换包（如 <a href="https://pub.dev/packages/pinyin"><code class="language-plaintext highlighter-rouge">pinyin</code></a>、<a href="https://pub.dev/packages/lpinyin"><code class="language-plaintext highlighter-rouge">lpinyin</code></a>），
看起来只要 <code class="language-plaintext highlighter-rouge">char→pinyin→compareTo</code> 三步就能解决问题。</p>

<p>但仔细分析后，这条路有几个绕不开的坑。</p>

<h4 id="231-阿拉伯文转写--丢失排序传统">2.3.1. 阿拉伯文：转写 = 丢失排序传统</h4>

<p>阿拉伯字母有自己独立的排序传统。根据 Wikipedia<sup>[2]</sup>，
阿拉伯文存在两种主要排序：传统的 Abjadi 序和现代词典使用的 hijāʾī 序，
两者都与拉丁字母 <code class="language-plaintext highlighter-rouge">a b c d...</code> 的排列完全不同。将阿拉伯字母转写为拉丁字母
（如 <code class="language-plaintext highlighter-rouge">ا</code>→<code class="language-plaintext highlighter-rouge">a</code>、<code class="language-plaintext highlighter-rouge">ب</code>→<code class="language-plaintext highlighter-rouge">b</code>）后按拉丁序排列，得到的结果与阿拉伯语使用者认知中的
正确顺序不相符。</p>

<p>换句话说，转写排序对阿拉伯文不只是「不精确」，而是从根本上用错了排序规则。</p>

<h4 id="232-中文硬编码字典的维护困境">2.3.2. 中文：硬编码字典的维护困境</h4>

<p>中文转拼音的核心问题不在转写逻辑本身，而在于<strong>依赖的数据</strong>。</p>

<p>现有拼音包（<code class="language-plaintext highlighter-rouge">pinyin</code>、<code class="language-plaintext highlighter-rouge">lpinyin</code> 等）的工作方式是
把 Unicode/Unihan 的字符→拼音映射表硬编码到 Dart 源文件中。
<a href="https://pub.dev/packages/pinyin"><code class="language-plaintext highlighter-rouge">pinyin</code></a> 最新版发布于 2 年前，字典数据来自 Unihan 15.0.0；
<a href="https://pub.dev/packages/lpinyin"><code class="language-plaintext highlighter-rouge">lpinyin</code></a> 更是<strong>5 年</strong>未曾更新。Unihan 每年都有新版本——新增字符、
修正读音、补充多音字语境——但字典就此冻结。用户输入的新 Unicode 汉字可能转拼音失败，
官方修正的读音错误也不会被同步。</p>

<h4 id="233-小结">2.3.3. 小结</h4>

<p>转拼音再排序看上去是一条捷径，但因为转写过程<strong>丢弃了原始语言的排序语义</strong>，
对于有独立排序传统的书写系统（如阿拉伯文的 Abjadi/hijāʾī 序）会得到错误结果。
加上 pub.dev 上拼音包的维护现状堪忧，这条路走不通。</p>

<h3 id="solution-b">2.4. 方案 B：<code class="language-plaintext highlighter-rouge">collection.compareNatural</code></h3>

<p>Dart 官方的 <a href="https://pub.dev/packages/collection"><code class="language-plaintext highlighter-rouge">collection</code></a> 包提供了 <code class="language-plaintext highlighter-rouge">compareNatural</code> 函数，
专门用于自然排序。从 Wikipedia Natural Sort Order<sup>[3]</sup> 列出的各语言实现中也能看到它。</p>

<p><strong>优点</strong>：Dart 官方维护，零额外依赖（<code class="language-plaintext highlighter-rouge">collection</code> 已是 Flutter 的传递依赖），数字排序正确。</p>

<p><strong>缺点</strong>：<code class="language-plaintext highlighter-rouge">compareNatural</code> 只解决数字排序，<strong>不支持 locale 感知</strong>。
它对中文仍然按 Unicode 码点排序，对变音符号也无力处理。本质上只是「更好的方案 A」。</p>

<p><strong>结论</strong>：可以作为 fallback，但不能作为最终方案。</p>

<h3 id="solution-c">2.5. 方案 C：打包传统 ICU 数据</h3>

<p>ICU（International Components for Unicode）是 Unicode 官方的 i18n 库，
包含完整的 collation 实现。部分 Dart 包通过 FFI 链接 ICU，将 collation 数据打包进 App。</p>

<p><strong>优点</strong>：功能完整，排序结果正确。</p>

<p><strong>缺点</strong>：</p>

<ul>
  <li>ICU collation 数据约 10~30MB，对移动端应用是显著的体积代价</li>
  <li>需要编译原生库，跨平台构建复杂</li>
  <li>collation 数据版本固定，不会随 OS 更新自动改善</li>
</ul>

<p><strong>结论</strong>：功能强但太重，更适合需要完全掌控 ICU 的场景。</p>

<h3 id="solution-d">2.6. 方案 D：<code class="language-plaintext highlighter-rouge">icu4x</code> + <code class="language-plaintext highlighter-rouge">intl4x</code></h3>

<p><a href="https://github.com/unicode-org/icu4x">ICU4X</a> 是 Unicode 官方用 Rust 重写的、面向资源受限环境的新一代 i18n 库，
设计目标包含「small and modular code」「pluggable locale data」。
Dart 侧由官方 <a href="https://github.com/dart-lang/i18n/tree/main/pkgs/intl4x"><code class="language-plaintext highlighter-rouge">intl4x</code></a> 包提供 FFI 封装，已支持 collation。</p>

<p>这是最接近「官方方案」的选择，需要重点分析。</p>

<p>参考 <a href="https://github.com/dart-lang/i18n/blob/main/pkgs/intl4x/lib/collation.dart">intl4x/lib/collation.dart</a>:</p>

<div class="language-dart highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">final</span> <span class="n">list</span> <span class="o">=</span> <span class="p">[</span><span class="s">'Z'</span><span class="p">,</span> <span class="s">'a'</span><span class="p">,</span> <span class="s">'z'</span><span class="p">,</span> <span class="s">'ä'</span><span class="p">];</span>
<span class="kd">final</span> <span class="n">german</span> <span class="o">=</span> <span class="n">Collation</span><span class="p">(</span><span class="nl">locale:</span> <span class="n">Locale</span><span class="o">.</span><span class="na">parse</span><span class="p">(</span><span class="s">'de-DE'</span><span class="p">));</span>
<span class="n">list</span><span class="o">.</span><span class="na">sort</span><span class="p">(</span><span class="n">german</span><span class="o">.</span><span class="na">compare</span><span class="p">);</span>
<span class="n">print</span><span class="p">(</span><span class="n">list</span><span class="p">);</span> <span class="c1">// [a, ä, z, Z]</span>
</code></pre></div></div>

<p><strong>优点</strong>：</p>

<ul>
  <li>Dart 官方维护（<code class="language-plaintext highlighter-rouge">labs.dart.dev</code>），长期支持有保障</li>
  <li>功能完整：collation + datetime + number + plural + display names</li>
  <li>基于 ICU4X，设计上比传统 ICU 更模块化</li>
  <li>全平台支持</li>
</ul>

<p><strong>缺点</strong>：</p>

<ul>
  <li><strong>二进制体积显著增加</strong>：README 明确声明「This package currently increases binary size in non-web builds significantly」，需使用 <code class="language-plaintext highlighter-rouge">--enable-experiment=record-use</code> 实验性标志缓解</li>
  <li><strong>Flathub 构建需额外预处理</strong>：<code class="language-plaintext highlighter-rouge">intl4x</code> 依赖 <code class="language-plaintext highlighter-rouge">icu4x</code> FFI 原生库，后者需要编译
Rust ICU4X → <code class="language-plaintext highlighter-rouge">.so</code>/<code class="language-plaintext highlighter-rouge">.dylib</code>/<code class="language-plaintext highlighter-rouge">.dll</code>。而 Flathub 构建沙箱完全离线，无法
直接 <code class="language-plaintext highlighter-rouge">cargo build</code> 下载依赖——这正是 <a href="/post/202507/flutter-flatpak-and-flathub/">上篇 Flathub 博客</a>
中踩过的坑。可行的绕过方案有两种：一是按官方推荐的流程预先编译各平台
二进制并随源码入库；二是在 flatpak-builder manifest 中手动添加
Rust vendoring + offline 编译模块。无论哪种，都大幅增加了 Flatpak
打包的维护复杂度。</li>
  <li><strong>实验阶段</strong>：当前稳定版 0.17.0，API 仍在迭代，<code class="language-plaintext highlighter-rouge">icu4x</code> Dart 绑定更是只有 0.0.1 稳定版，实际依赖 pre-release（2.2.0-dev.3）</li>
  <li><strong>重量级依赖</strong>：仅为了 collation 功能，需要引入整个 i18n 框架（datetime、number、plural、case mapping 等），对只需要字符串排序的场景是明显的过度引入</li>
</ul>

<p><strong>结论</strong>：功能最强、官方背书，但太重且 Flathub 打包维护成本高，且自身（截至 2026-08）来说还没有进入稳定版本的 Release，使用风险大。</p>

<h3 id="solution-e">2.7. 方案 E：调用平台原生排序引擎</h3>

<p>每个操作系统都已经内置了成熟的排序引擎——Android 有 <code class="language-plaintext highlighter-rouge">android.icu.text.Collator</code>，
iOS/macOS 有 <code class="language-plaintext highlighter-rouge">localizedStandardCompare:</code>，Linux 有 <code class="language-plaintext highlighter-rouge">libicui18n</code>，
Windows 有 <code class="language-plaintext highlighter-rouge">CompareStringEx</code>，Web 有 <code class="language-plaintext highlighter-rouge">Intl.Collator</code><sup>[4]</sup>。</p>

<p>这些 API 一直在系统后台默默工作——Finder 按名称排列文件、Android 文件管理器排序、
Windows 资源管理器排序，靠的都是它们。那能不能直接调它们呢？</p>

<p>这个方案走的是<strong>混合路线</strong>：桌面端优先用 FFI（同步）直调系统库，
移动端用 Platform Method（异步）桥接原生 API，对外统一封装为 <code class="language-plaintext highlighter-rouge">Future</code> 接口。
Linux 走 FFI→<code class="language-plaintext highlighter-rouge">libicui18n</code>，Windows FFI→ICU DLL 优先、Pigeon→<code class="language-plaintext highlighter-rouge">CompareStringEx</code> 作为 fallback，
Android/iOS/macOS 因为文档没暴露 FFI 友好的公开 collation 入口，出于维护性考虑走平台桥接。</p>

<p><strong>优点</strong>：</p>

<ul>
  <li><strong>零额外体积</strong>：不打包任何 collation 数据，直接调用 OS 内置 API</li>
  <li><strong>系统一致性</strong>：排序结果与系统文件管理器完全一致</li>
  <li><strong>自动更新</strong>：collation 数据随 OS 更新自动改善，无需升级包版本</li>
  <li><strong>Flathub 友好</strong>：无额外下载/编译依赖，纯 Dart + Pigeon/FFI 调用系统 API</li>
  <li><strong>全平台</strong>：Flutter 支持的主流平台都有成熟的系统级 collation API</li>
</ul>

<p><strong>缺点</strong>：</p>

<ul>
  <li>需要为每个平台编写不同的桥接代码（FFI / Platform Method）</li>
  <li><strong>部分平台需要异步调用</strong>：Android/iOS/macOS 走平台通道（异步），
这意味着不能直接把比较函数传给 <code class="language-plaintext highlighter-rouge">List.sort()</code>。
不过 Linux（FFI→ICU）和 Windows（FFI→ICU DLL）在底层是同步的——
为了 API 一致性，包对外统一返回 <code class="language-plaintext highlighter-rouge">Future</code>，上层按异步处理即可</li>
  <li>不同平台的 API 行为可能有细微差异</li>
  <li>需要在 Dart 侧做统一的错误处理和 Degrade Fallback</li>
</ul>

<p><strong>结论</strong>：这个方案轻量、Flathub 兼容好，体积小，是一个很好的平衡点，适合对多平台统一没有对决掌控力需求，但希望可以快速获得自然语言排序能力或者希望可以和系统体验尽量一致的应用。</p>

<h2 id="3-落地">3. 落地</h2>

<h3 id="31-阶段一在-mhabit-内部实现平台封装">3.1. 阶段一：在 mhabit 内部实现平台封装</h3>

<p>确定了方案 E 之后，第一件事不是立刻创建独立 package——
而是先在应用内部把各平台的排序调用跑通，验证方案的可行性。</p>

<p>实现的核心思路：</p>

<ul>
  <li>在 Dart 侧定义一个统一的排序接口（<code class="language-plaintext highlighter-rouge">SortItem</code> + <code class="language-plaintext highlighter-rouge">NativeSort.sort()</code>）</li>
  <li>桌面端通过 Dart FFI 直调系统 collation 库
    <ul>
      <li>Linux→<code class="language-plaintext highlighter-rouge">libicui18n</code></li>
      <li>Windows→ICU DLL，也提供 Pigeon fallback（<code class="language-plaintext highlighter-rouge">CompareStringEx</code>）</li>
    </ul>
  </li>
  <li>移动端则通过 Pigeon（Android/iOS/macOS）桥接原生 API</li>
</ul>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>NativeSort (Dart API)
├── Android   → Pigeon → android.icu.text.Collator
├── iOS       → Pigeon → localizedStandardCompare:
├── macOS     → Pigeon → localizedStandardCompare:
├── Linux     → Dart FFI → system libicui18n
├── Windows   → Pigeon (CompareStringEx) 或 FFI (ICU)
└── Web       → Dart → Intl.Collator
</code></pre></div></div>

<h3 id="32-阶段二提取为独立-package">3.2. 阶段二：提取为独立 package</h3>

<p>在自己项目内部跑通后，发现这段代码有三个理由值得独立为 Publish Package：</p>

<ol>
  <li>
    <p><strong>pub.dev 上没有类似的全平台原生排序方案</strong>。<code class="language-plaintext highlighter-rouge">collection.compareNatural</code> 只解决数字排序，
<code class="language-plaintext highlighter-rouge">intl4x</code> 太重且实验性——在「轻量、全平台、原生排序」这个细分定位上，存在明显的空白。</p>
  </li>
  <li>
    <p><strong>Flathub 兼容是刚需</strong>。mhabit 已经上架 Flathub，任何需要额外下载/编译的依赖都是不可接受的。
而 <code class="language-plaintext highlighter-rouge">intl4x</code>/<code class="language-plaintext highlighter-rouge">icu4x</code> 正是需要额外 FFI 编译，处理需要 Patch 或者预编译，相当麻烦和脆弱，已经在 sqlite3 package 上踩过坑。</p>
  </li>
  <li>
    <p><strong>解耦与复用</strong>。排序逻辑与业务无关，独立出去后可以单独测试、独立发版，
其他 Flutter 项目也可以直接引用。</p>
  </li>
</ol>

<p>于是 <a href="https://pub.dev/packages/native_natural_sort"><strong>native_natural_sort</strong></a> 便顺势出现了。</p>

<h3 id="33-mhabit-项目接入">3.3. mhabit 项目接入</h3>

<p>mhabit 接入 <code class="language-plaintext highlighter-rouge">native_natural_sort</code> 的改动集中在几个文件。</p>

<p>参考 <a href="https://github.com/FriesI23/mhabit/blob/main/lib/main.dart">lib/main.dart</a>:</p>

<div class="language-dart highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">await</span> <span class="nf">NativeSort</span><span class="p">()</span><span class="o">.</span><span class="na">config</span><span class="p">(</span>
  <span class="n">SortOptions</span><span class="p">(</span>
    <span class="nl">windows:</span> <span class="n">SortOptionsWindows</span><span class="o">.</span><span class="na">ffi</span><span class="p">(</span><span class="nl">onLoadFailed:</span> <span class="n">WindowsSort</span><span class="o">.</span><span class="na">pigeon</span><span class="p">),</span>
  <span class="p">),</span>
<span class="p">);</span>
</code></pre></div></div>

<p>在 <code class="language-plaintext highlighter-rouge">main()</code> 中做一次性初始化。Windows 上配置了 FFI（ICU）→ Pigeon（<code class="language-plaintext highlighter-rouge">CompareStringEx</code>）的双后端 fallback。</p>

<p>参考 <a href="https://github.com/FriesI23/mhabit/blob/main/lib/extensions/collation_extensions.dart">lib/extensions/collation_extensions.dart</a>，
封装了一个通用的 <code class="language-plaintext highlighter-rouge">naturalSort&lt;T&gt;</code> 扩展方法，接收 <code class="language-plaintext highlighter-rouge">idOf</code>/<code class="language-plaintext highlighter-rouge">valueOf</code> 函数来适配任意数据类型：</p>

<div class="language-dart highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">extension</span> <span class="n">NaturalSortExtension</span> <span class="kd">on</span> <span class="n">NativeSort</span> <span class="p">{</span>
  <span class="n">Future</span><span class="p">&lt;</span><span class="kt">List</span><span class="p">&lt;</span><span class="n">T</span><span class="p">&gt;&gt;</span> <span class="n">naturalSort</span><span class="p">&lt;</span><span class="n">T</span><span class="p">&gt;({</span>
    <span class="kd">required</span> <span class="kt">List</span><span class="p">&lt;</span><span class="n">T</span><span class="p">&gt;</span> <span class="n">items</span><span class="p">,</span>
    <span class="kd">required</span> <span class="kt">String</span> <span class="kt">Function</span><span class="p">(</span><span class="n">T</span><span class="p">)</span> <span class="n">idOf</span><span class="p">,</span>
    <span class="kd">required</span> <span class="kt">String</span> <span class="kt">Function</span><span class="p">(</span><span class="n">T</span><span class="p">)</span> <span class="n">valueOf</span><span class="p">,</span>
    <span class="kt">bool</span> <span class="n">descending</span> <span class="o">=</span> <span class="kc">false</span><span class="p">,</span>
    <span class="kt">String</span><span class="o">?</span> <span class="n">locale</span><span class="p">,</span>
  <span class="p">})</span> <span class="kd">async</span> <span class="p">{</span>
    <span class="k">if</span> <span class="p">(</span><span class="n">items</span><span class="o">.</span><span class="na">isEmpty</span><span class="p">)</span> <span class="k">return</span> <span class="n">items</span><span class="p">;</span>
    <span class="kd">final</span> <span class="n">sortItems</span> <span class="o">=</span> <span class="n">items</span>
        <span class="o">.</span><span class="na">map</span><span class="p">((</span><span class="n">e</span><span class="p">)</span> <span class="o">=</span><span class="p">&gt;</span> <span class="n">SortItem</span><span class="p">(</span><span class="nl">id:</span> <span class="n">idOf</span><span class="p">(</span><span class="n">e</span><span class="p">),</span> <span class="nl">value:</span> <span class="n">valueOf</span><span class="p">(</span><span class="n">e</span><span class="p">)))</span>
        <span class="o">.</span><span class="na">toList</span><span class="p">();</span>
    <span class="kd">final</span> <span class="n">sorted</span> <span class="o">=</span> <span class="k">await</span> <span class="n">sort</span><span class="p">(</span><span class="n">sortItems</span><span class="p">,</span> <span class="cm">/* ... */</span><span class="p">);</span>
    <span class="c1">// Map sorted IDs back to original items.</span>
    <span class="c1">// ...</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>在 ViewModel 层，排序逻辑通过 <code class="language-plaintext highlighter-rouge">SortGuard</code> 做了 degrade fallback：</p>

<ul>
  <li>当 natural sort 启用且排序方式是「按名称」时，走异步的原生排序路径</li>
  <li>如果异步排序失败（如平台 API 异常），自动退回到 <code class="language-plaintext highlighter-rouge">compareTo</code> 默认排序，保证功能不崩溃</li>
  <li><code class="language-plaintext highlighter-rouge">SortGuard</code> 确保并发重排请求只保留最新一次的结果</li>
</ul>

<p>参考 <a href="https://github.com/FriesI23/mhabit/blob/main/lib/pages/habits_display/_providers/habit_summary.dart">lib/pages/habits_display/_providers/habit_summary.dart</a> 中 <code class="language-plaintext highlighter-rouge">_ResortHandler</code>：</p>

<div class="language-dart highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">class</span> <span class="nc">_ResortHandler</span> <span class="p">{</span>
  <span class="n">FutureOr</span><span class="p">&lt;</span><span class="n">_HabitsSortableCache</span><span class="p">&gt;</span> <span class="n">call</span><span class="p">()</span> <span class="p">{</span>
    <span class="kd">final</span> <span class="n">needHabitNatural</span> <span class="o">=</span>
        <span class="n">cache</span><span class="o">.</span><span class="na">sortType</span> <span class="o">==</span> <span class="n">HabitDisplaySortType</span><span class="o">.</span><span class="na">name</span> <span class="o">&amp;&amp;</span>
        <span class="n">_vm</span><span class="o">.</span><span class="na">_naturalSortEnabled</span><span class="p">;</span>
    <span class="kd">final</span> <span class="n">needGroupNatural</span> <span class="o">=</span> <span class="cm">/* ... */</span><span class="p">;</span>

    <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="n">needHabitNatural</span> <span class="o">&amp;&amp;</span> <span class="o">!</span><span class="n">needGroupNatural</span><span class="p">)</span> <span class="k">return</span> <span class="n">defaultSort</span><span class="p">();</span>
    <span class="k">return</span> <span class="n">_guardedNaturalSort</span><span class="p">(</span><span class="n">needHabitNatural</span><span class="p">,</span> <span class="n">needGroupNatural</span><span class="p">);</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>同时还通过 <code class="language-plaintext highlighter-rouge">NaturalSortExperimentalFeature</code> 提供了一个开关，
让用户可以自行选择是否启用原生排序（默认开启）。</p>

<h3 id="34-异步改造sortguard-模式">3.4. 异步改造：SortGuard 模式</h3>

<p>方案 E 的混合路线带来了一个工程上的实际问题：
Linux/Windows 的 FFI 排序在底层是同步的，但 Android/iOS/macOS 的 Pigeon 排序是异步的。
为了 API 一致性，<code class="language-plaintext highlighter-rouge">native_natural_sort</code> 对外统一返回 <code class="language-plaintext highlighter-rouge">Future</code>。
这意味着原有的同步排序逻辑——所有方案 A~D 都能直接 <code class="language-plaintext highlighter-rouge">list.sort(compareFn)</code> ——需要异步化改造。</p>

<p>mhabit 的做法是引入 <code class="language-plaintext highlighter-rouge">SortGuard</code>——一个简单的基于计数的排序守卫。
核心思路：</p>

<ul>
  <li>每次数据重载前调用 <code class="language-plaintext highlighter-rouge">_sortGuard.bump()</code>，递增代数</li>
  <li>排序时用 <code class="language-plaintext highlighter-rouge">_sortGuard.run()</code> 包装，捕获当前代数</li>
  <li>异步排序完成后比对代数——如果不一致，说明数据已变，结果作废</li>
</ul>

<p>参考 <a href="https://github.com/FriesI23/mhabit/blob/main/lib/common/sort_generation.dart">lib/common/sort_generation.dart</a>：</p>

<div class="language-dart highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">class</span> <span class="nc">SortGuard</span> <span class="p">{</span>
  <span class="kt">int</span> <span class="n">_generation</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span>

  <span class="kt">void</span> <span class="n">bump</span><span class="p">()</span> <span class="p">{</span> <span class="n">_generation</span><span class="o">++</span><span class="p">;</span> <span class="p">}</span>

  <span class="n">FutureOr</span><span class="p">&lt;</span><span class="n">T</span><span class="o">?</span><span class="p">&gt;</span> <span class="n">run</span><span class="p">&lt;</span><span class="n">T</span><span class="p">&gt;(</span><span class="n">FutureOr</span><span class="p">&lt;</span><span class="n">T</span><span class="p">&gt;</span> <span class="kt">Function</span><span class="p">()</span> <span class="n">sort</span><span class="p">)</span> <span class="p">{</span>
    <span class="kd">final</span> <span class="n">token</span> <span class="o">=</span> <span class="n">_generation</span><span class="p">;</span>
    <span class="kd">final</span> <span class="n">result</span> <span class="o">=</span> <span class="n">sort</span><span class="p">();</span>
    <span class="k">return</span> <span class="n">result</span> <span class="k">is</span> <span class="n">Future</span><span class="p">&lt;</span><span class="n">T</span><span class="p">&gt;</span>
        <span class="o">?</span> <span class="n">result</span><span class="o">.</span><span class="na">then</span><span class="p">((</span><span class="n">r</span><span class="p">)</span> <span class="o">=</span><span class="p">&gt;</span> <span class="n">_generation</span> <span class="o">==</span> <span class="n">token</span> <span class="o">?</span> <span class="n">r</span> <span class="o">:</span> <span class="kc">null</span><span class="p">)</span>
        <span class="o">:</span> <span class="p">(</span><span class="n">_generation</span> <span class="o">==</span> <span class="n">token</span> <span class="o">?</span> <span class="n">result</span> <span class="o">:</span> <span class="kc">null</span><span class="p">);</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>在 ViewModel 中，<code class="language-plaintext highlighter-rouge">_resortData()</code> 的返回类型从 <code class="language-plaintext highlighter-rouge">void</code> 改为 <code class="language-plaintext highlighter-rouge">FutureOr&lt;void&gt;</code>——
同步路径（默认排序）直接返回，异步路径（原生排序）返回 <code class="language-plaintext highlighter-rouge">Future</code>，
调用方用 <code class="language-plaintext highlighter-rouge">await _resortData()</code> 统一处理，接口不变。</p>

<p>配合 <code class="language-plaintext highlighter-rouge">.catchError((e) =&gt; defaultSort())</code> 的 degrade fallback，
异步改造对上层完全透明。</p>

<h3 id="35-最终效果">3.5. 最终效果</h3>

<p>接入 <a href="https://pub.dev/packages/native_natural_sort"><code class="language-plaintext highlighter-rouge">native_natural_sort</code></a> 后，按名称排序的结果对比：</p>

<table>
  <thead>
    <tr>
      <th>输入</th>
      <th>Dart <code class="language-plaintext highlighter-rouge">compareTo</code></th>
      <th><code class="language-plaintext highlighter-rouge">native_natural_sort</code></th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">item2, item10</code></td>
      <td><code class="language-plaintext highlighter-rouge">item10, item2</code></td>
      <td><code class="language-plaintext highlighter-rouge">item2, item10</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">árbol, amor, azul</code></td>
      <td><code class="language-plaintext highlighter-rouge">amor, azul, árbol</code></td>
      <td><code class="language-plaintext highlighter-rouge">amor, árbol, azul</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">张, 李, 王</code></td>
      <td><code class="language-plaintext highlighter-rouge">张, 李, 王</code></td>
      <td><code class="language-plaintext highlighter-rouge">李, 王, 张</code>（拼音）</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Straße, Strasse</code></td>
      <td><code class="language-plaintext highlighter-rouge">Strasse, Straße</code></td>
      <td>相等（德语 ß = ss）</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">v1, v2, v10</code></td>
      <td><code class="language-plaintext highlighter-rouge">v1, v10, v2</code></td>
      <td><code class="language-plaintext highlighter-rouge">v1, v2, v10</code></td>
    </tr>
  </tbody>
</table>

<p>可以看到前面提到的问题都得到了很好的解决。</p>

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

<p>回头看整个过程，核心教训是：<strong>字符串排序看似简单，一旦涉及多语言用户，就不要自己造轮子</strong>。</p>

<p>Unicode Collation Algorithm（UCA）是一个发展了数十年的标准，各操作系统已经内置了经过充分测试的实现。
与其打包 ICU 数据或依赖实验性框架，直接调用 OS 原生 API 是更务实的选择——0体积增长（无需维护 ICU Database）、系统级正确性、随 OS 更新自动改善。</p>

<p>该 Package 目前已在 <a href="https://pub.dev/packages/native_natural_sort">pub.dev/native_natural_sort</a> 发布，如果你也在做多语言 Flutter 应用且遇到了类似的问题，希望这个 Package 能有所帮助。</p>

<h2 id="参考文献">参考文献</h2>

<ol>
  <li><a href="https://en.wikipedia.org/wiki/Unicode_collation_algorithm">Unicode Collation Algorithm (UCA)</a> — Wikipedia</li>
  <li><a href="https://en.wikipedia.org/wiki/Abjad_numerals">Abjad numerals</a> — Wikipedia</li>
  <li><a href="https://en.wikipedia.org/wiki/Natural_sort_order">Natural sort order</a> — Wikipedia</li>
  <li><a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Collator">Intl.Collator</a> — MDN Web Docs</li>
</ol>

<!-- refs -->]]></content><author><name>Fries_I23</name></author><category term="flutter" /><category term="flutter" /><category term="dart" /><category term="natural-sort" /><category term="native-natural-sort" /><category term="collation" /><summary type="html"><![CDATA[Dart 默认的 compareTo 在人类友好的名称排序场景下问题重重：大小写、CJK 字符、变音符号、数字排序。 本文记录从发现问题到调研五种技术方案，最终开发出 native_natural_sort 包并接入 mhabit 项目的完整过程。]]></summary></entry><entry><title type="html">Flutter 应用上架 Microsoft Store：Entra ID 应用注册 + GitHub Actions 自动提交全流程</title><link href="https://friesi23.icu/post/202606/flutter-windows-store-submission-automation" rel="alternate" type="text/html" title="Flutter 应用上架 Microsoft Store：Entra ID 应用注册 + GitHub Actions 自动提交全流程" /><published>2026-06-27T18:00:00+00:00</published><updated>2026-06-27T18:00:00+00:00</updated><id>https://friesi23.icu/post/202606/flutter-windows-store-submission-automation</id><content type="html" xml:base="https://friesi23.icu/post/202606/flutter-windows-store-submission-automation"><![CDATA[<p>最近给 Flutter 应用接入了 Microsoft Store 的自动化提交流程。本以为账号配置会是个一次性的体力活——注册、拿凭证、写进 CI——结果合作伙伴中心（Partner Center）和 Entra ID 这一侧本身就踩了几个坑，落地之后 CI 实跑又冒出来几个更隐蔽的问题。因此在这里记录一下。</p>

<h2 id="1-背景">1. 背景</h2>

<p><a href="https://github.com/FriesI23/mhabit">Table Habit</a> 的发布自动化已经通过 <a href="https://fastlane.tools">fastlane</a> 覆盖了 Android（Google Play）和 iOS/macOS（App Store）——Android 侧配置见 <a href="https://github.com/FriesI23/mhabit/blob/main/android/fastlane/Fastfile"><code class="language-plaintext highlighter-rouge">android/fastlane/Fastfile</code></a>，iOS/macOS 同理。接下来推进 Windows / Microsoft Store。</p>

<p>最初的设想是：Windows 这边也应该能找到类似 fastlane 的现成方案直接套上去。结果翻了一圈官方文档和社区项目，并没有找到一个等价物——最后能用的就是 Microsoft 官方的 <a href="https://github.com/microsoft/msstore-cli"><code class="language-plaintext highlighter-rouge">msstore</code> CLI</a>，剩下的部分（账号配置、CI 拼装）都得自己手动走一遍。</p>

<p>落地 CI 之前需要先确认两件事：</p>

<ol>
  <li>应用必须已在 Partner Center 中创建，拿到应用 ID（Product ID）并在 GitHub 中配置
——这个值跟 iOS/macOS 的 Bundle ID 一样是公开标识。</li>
  <li>代表 CI 的 Microsoft Entra ID 应用注册（App Registration）必须在
<a href="https://learn.microsoft.com/en-us/partner-center/enroll/service-principal">合作伙伴中心（Partner Center）</a> 分配「经理 (Windows)」角色。</li>
</ol>

<p>下面先走一遍 Partner Center + Entra ID + <code class="language-plaintext highlighter-rouge">msstore</code> CLI 的手动配置流程，再落地代码。最终 CI 落地的成品见：</p>

<ul>
  <li><a href="https://github.com/FriesI23/mhabit/blob/main/.github/workflows/_submit-windows-store.yml"><code class="language-plaintext highlighter-rouge">_submit-windows-store.yml</code></a></li>
  <li><a href="https://github.com/FriesI23/mhabit/blob/main/.github/actions/build_windows_store/action.yml"><code class="language-plaintext highlighter-rouge">build_windows_store</code></a></li>
  <li><a href="https://github.com/FriesI23/mhabit/blob/main/scripts/build_msix_store.cmd"><code class="language-plaintext highlighter-rouge">scripts/build_msix_store.cmd</code></a></li>
  <li><a href="https://github.com/FriesI23/mhabit/blob/main/scripts/msstore/check_draft_ownership.ps1"><code class="language-plaintext highlighter-rouge">scripts/msstore/check_draft_ownership.ps1</code></a></li>
  <li><a href="https://github.com/FriesI23/mhabit/blob/main/scripts/msstore/update_changelog.ps1"><code class="language-plaintext highlighter-rouge">scripts/msstore/update_changelog.ps1</code></a></li>
</ul>

<h2 id="2-合作伙伴中心partner-center账号侧配置">2. 合作伙伴中心（Partner Center）账号侧配置</h2>

<h3 id="21-关联-microsoft-entra-id-租户tenant">2.1. 关联 Microsoft Entra ID 租户（Tenant）</h3>

<p>可能很多独立开发者跟我一样以为关联 Entra ID 租户（Tenant，以前叫 Azure AD）需要公司账号——其实 <strong>个人开发者账号也能关联 Entra ID 租户</strong>，官方文档明确支持个人账号事后关联 Entra ID 做多用户管理。具体操作步骤：</p>

<ol>
  <li>用注册合作伙伴中心（Partner Center）开发者账号的同一个 Microsoft 账号登录 <a href="https://entra.microsoft.com">entra.microsoft.com</a>——账号一致，后面关联起来省事。</li>
  <li>左侧导航找「标识」→「概述」，或者直接搜”创建租户”，进入创建向导后租户类型选 <strong>Microsoft Entra ID</strong>（不是面向终端用户的 Entra External ID/B2C，那个是给应用做用户认证用的，跟这里的 CI 场景没关系）。</li>
  <li>填写组织名称、初始域名（格式固定是 <code class="language-plaintext highlighter-rouge">&lt;自定义前缀&gt;.onmicrosoft.com</code>，全局唯一，先到先得）和国家/地区，提交创建。
    <blockquote>
      <p>第一次提交的时候撞上了 <code class="language-plaintext highlighter-rouge">Error Code: 715-123280</code>，换浏览器、清缓存都没用，一度以为是 provisioning 流程被自动校验挡住的已知问题。后来发现根本原因是<strong>当前登录的 Microsoft 账号邮箱本身就没法用来创建 Entra ID 租户</strong>——换一个邮箱重新登录、重新提交就解决了。如果你也撞上这个错误码，先试试换邮箱。</p>
    </blockquote>
  </li>
  <li>创建过程中会要求绑定一张信用卡——按 <a href="https://learn.microsoft.com/en-us/azure/cost-management-billing/manage/microsoft-entra-id-free">Microsoft Entra ID Free 官方说明</a>，这张卡只用于身份验证，是 Entra ID Free 的强制步骤，照提示填实际卡信息即可。</li>
  <li>流程一般几分钟就能跑完，完成后页面会自动切到新租户。记下这个租户的域名和 Tenant ID。</li>
  <li>回到合作伙伴中心，点右上角齿轮图标 → 「帐户设置」，左侧导航「组织配置文件」分组下选 <strong>Tenants</strong>（这一项没有被本地化，显示的就是英文原文）→「将 Microsoft Entra ID 与合作伙伴中心账号关联」，用第 5 步那个租户里的账号登录确认域名信息，点「确认」完成关联（详见官方文档 <a href="https://learn.microsoft.com/en-us/windows/apps/publish/partner-center/associate-existing-azure-ad-tenant-with-partner-center-account">Associate an existing Microsoft Entra ID tenant</a>）。关联完成后，合作伙伴中心才能在 2.3 里识别到这个租户下创建的 App Registration。</li>
</ol>

<p>详见 <a href="https://learn.microsoft.com/en-us/entra/">Microsoft Entra ID 文档</a>。</p>

<p><img src="https://cdn.jsdelivr.net/gh/FriesI23/blog-image@master/img/flutter-windows-store-submission-automation-01%20-2.png" alt="entra.microsoft.com 新建 tenant 流程" width="800" /></p>

<h3 id="sec-2-2">2.2. 创建 CI 用的应用注册（App Registration）</h3>

<p>在 Entra ID 租户里创建一个应用注册（App Registration）代表 CI，具体操作步骤（详见 <a href="https://learn.microsoft.com/en-us/entra/identity-platform/quickstart-register-app">应用注册快速入门</a>）：</p>

<ol>
  <li>登录 <a href="https://entra.microsoft.com">entra.microsoft.com</a>，确认当前租户已经切到 2.1 里新建的那个（右上角齿轮图标可以切换租户）。</li>
  <li>
    <p>左侧导航找到「应用注册」，点击「新注册」。</p>

    <p><img src="https://cdn.jsdelivr.net/gh/FriesI23/blog-image@master/img/flutter-windows-store-submission-automation-02.png" alt="Entra「应用注册」列表页，左侧导航和「新注册」按钮位置" width="800" /></p>
  </li>
  <li>填写一个能区分用途的名称（比如 <code class="language-plaintext highlighter-rouge">mhabit-windows-store-ci</code>）；「受支持的帐户类型」选 <strong>仅单个租户</strong>——这个 app 只代表自己的 CI，不需要兼容多租户或个人账号登录。</li>
  <li>
    <p>「重定向 URI」整段留空——CI 走的是客户端凭据流程（client credentials flow），靠 Client ID + Secret 直接换 token，没有用户登录跳转这一步，不需要配回调地址。</p>

    <p><img src="https://cdn.jsdelivr.net/gh/FriesI23/blog-image@master/img/flutter-windows-store-submission-automation-03.png" alt="「注册应用程序」表单：受支持的帐户类型选「仅单个租户」，重定向 URI 留空" width="800" /></p>
  </li>
  <li>
    <p>点击「注册」，进入应用的概述页，记下 <strong>目录(租户) ID</strong> 和 <strong>应用程序(客户端) ID</strong>——这两个就是后面要用的 Tenant ID 和 Client ID。</p>

    <p><img src="https://cdn.jsdelivr.net/gh/FriesI23/blog-image@master/img/flutter-windows-store-submission-automation-04.png" alt="应用概述页：应用程序(客户端) ID 与目录(租户) ID 的位置" width="800" /></p>
  </li>
  <li>
    <p>左侧导航切到「证书和密码」→「客户端密码」→「新客户端密码」，填写「说明」和「截止期限」（官方推荐 180 天/6 个月），点击「添加」。</p>

    <p><img src="https://cdn.jsdelivr.net/gh/FriesI23/blog-image@master/img/flutter-windows-store-submission-automation-05.png" alt="「证书和密码」页：新建客户端密码的导航路径与表单" width="800" /></p>
  </li>
  <li>
    <p>添加完成后客户端密码列表会多出一行，<strong>「值」</strong>这一列就是 Client Secret，只在这一次完整显示——当场复制保存，刷新或离开页面之后就只剩打码后的样子。注意旁边的「机密 ID」不是密码本身，只是这条密码记录的标识符，别认错。</p>

    <p><img src="https://cdn.jsdelivr.net/gh/FriesI23/blog-image@master/img/flutter-windows-store-submission-automation-06.png" alt="客户端密码列表：「值」与「机密 ID」是两个不同的字段" width="800" /></p>
  </li>
</ol>

<h3 id="23-给-app-分配经理-windows角色">2.3. 给 App 分配「经理 (Windows)」角色</h3>

<p>回到 Partner Center，路径是 Account settings → User management → Microsoft Entra applications → Add Microsoft Entra application，把上一步创建的 app 加进来并分配角色（详见 <a href="https://learn.microsoft.com/en-us/partner-center/enroll/service-principal">Partner Center 文档</a>）。</p>

<p>角色分配是个多选框——经理 (Windows)、开发人员、业务参与者、财务参与者、营销人员——很容易纠结要不要多选几个才保险。实际上官方文档只要求 <strong>经理 (Windows)</strong> 一个角色，它的权限范围本身已经覆盖”开发人员”能做的操作。</p>

<h3 id="sec-2-4">2.4. 获取卖家 ID（<code class="language-plaintext highlighter-rouge">MSSTORE_SELLER_ID</code>）</h3>

<p>这是我踩的最迷惑的一个坑。Partner Center 的”标识符”（Identifiers）页面会列出 <strong>Windows 发布者 ID</strong>（<code class="language-plaintext highlighter-rouge">CN=...</code> 格式，是包签名身份的 Publisher CN）、<strong>Windows Phone 发布者 ID</strong>、<strong>Symantec ID</strong>——这里面 <strong>没有一个</strong>是 Seller ID。</p>

<table>
  <tbody>
    <tr>
      <td>真正的 Seller ID 藏在 <strong>帐户设置 (Account settings) → Legal info</strong>，是一个纯数字——这里有个小陷阱：左侧导航栏里这一项压根没有被本地化，显示的就是英文原文 “Legal info”，只有进去之后页面标题才会同时显示”帐户设置</td>
      <td>法律信息”。对照一下：</td>
    </tr>
  </tbody>
</table>

<table>
  <thead>
    <tr>
      <th>名称</th>
      <th>位置</th>
      <th>格式</th>
      <th>是不是 Seller ID</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Windows 发布者 ID</td>
      <td>标识符页面</td>
      <td><code class="language-plaintext highlighter-rouge">CN=...</code></td>
      <td>否</td>
    </tr>
    <tr>
      <td>Windows Phone 发布者 ID</td>
      <td>标识符页面</td>
      <td>-</td>
      <td>否</td>
    </tr>
    <tr>
      <td>Symantec ID</td>
      <td>标识符页面</td>
      <td>-</td>
      <td>否</td>
    </tr>
    <tr>
      <td>Seller ID</td>
      <td>Legal info 页面</td>
      <td>纯数字</td>
      <td><strong>是</strong></td>
    </tr>
  </tbody>
</table>

<p>这个命名上的混淆连官方 <code class="language-plaintext highlighter-rouge">msstore-cli</code> 的 issue 里都有人在问，文档里笼统说的 “Publisher ID / Seller ID” 和 Partner Center 页面上的实际字段名对不上，第一次确实很难找。</p>

<table>
  <tbody>
    <tr>
      <td>![帐户设置</td>
      <td>Legal info 页面：卖家 ID 与 Windows 发布者 ID、Windows Phone 发布者 ID 同列在「帐户详细信息」里](https://cdn.jsdelivr.net/gh/FriesI23/blog-image@master/img/flutter-windows-store-submission-automation-07.png){: width=”800” }</td>
    </tr>
  </tbody>
</table>

<h2 id="3-本机-msstore-cli-配置">3. 本机 <code class="language-plaintext highlighter-rouge">msstore</code> CLI 配置</h2>

<p>至此账号侧需要的 4 个值都齐了（租户 ID / Tenant ID、应用程序(客户端) ID / Client ID、客户端密码 / Client Secret、卖家 ID / Seller ID），接下来在本机装 <code class="language-plaintext highlighter-rouge">msstore</code> CLI 完成认证。</p>

<p>我的环境是 macOS，所以需要先有 <code class="language-plaintext highlighter-rouge">.NET 9 Runtime</code>，然后：</p>

<div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>brew <span class="nb">install </span>microsoft/msstore-cli/msstore-cli
</code></pre></div></div>

<p>安装完成后跑 <code class="language-plaintext highlighter-rouge">reconfigure</code> 把凭证写进本机配置：</p>

<div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>msstore reconfigure <span class="se">\</span>
  <span class="nt">--tenantId</span> &lt;TENANT_ID&gt; <span class="se">\</span>
  <span class="nt">--sellerId</span> &lt;SELLER_ID&gt; <span class="se">\</span>
  <span class="nt">--clientId</span> &lt;CLIENT_ID&gt; <span class="se">\</span>
  <span class="nt">--clientSecret</span> &lt;CLIENT_SECRET&gt;
</code></pre></div></div>

<ul>
  <li><code class="language-plaintext highlighter-rouge">TENANT_ID</code>：<a href="#sec-2-2">2.2</a> 第 5 步的「目录(租户) ID」</li>
  <li><code class="language-plaintext highlighter-rouge">SELLER_ID</code>：<a href="#sec-2-4">2.4</a> 节的 Seller ID</li>
  <li><code class="language-plaintext highlighter-rouge">CLIENT_ID</code>：<a href="#sec-2-2">2.2</a> 第 5 步的「应用程序(客户端) ID」</li>
  <li><code class="language-plaintext highlighter-rouge">CLIENT_SECRET</code>：<a href="#sec-2-2">2.2</a> 第 7 步的客户端密码「值」</li>
</ul>

<p>认证完成后用 <code class="language-plaintext highlighter-rouge">apps list</code> 获取 Product ID，需要跟仓库 README 里已经公开的 Microsoft Store 徽章链接（<code class="language-plaintext highlighter-rouge">apps.microsoft.com/detail/&lt;PRODUCT_ID&gt;</code>）做一次比对，确认没取错应用：</p>

<div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>msstore apps list

...
✅ Retrieved Managed Applications
┌───┬──────────────┬──────────────┬─────────────────────┐
│   │ ProductId    │ Display Name │ PackageId           │
├───┼──────────────┼──────────────┼─────────────────────┤
│ 1 │ 9N<span class="k">********</span>GZ │ Table Habit  │ Friesi23.TableHabit │
└───┴──────────────┴──────────────┴─────────────────────┘
</code></pre></div></div>

<h2 id="4-写进-github-environment">4. 写进 GitHub Environment</h2>

<p>至此拿到了 5 个值，但 Secrets 和普通变量需要分别处理：</p>

<table>
  <thead>
    <tr>
      <th>值</th>
      <th>性质</th>
      <th>写入方式</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">MSSTORE_TENANT_ID</code> / <code class="language-plaintext highlighter-rouge">MSSTORE_CLIENT_ID</code> / <code class="language-plaintext highlighter-rouge">MSSTORE_CLIENT_SECRET</code> / <code class="language-plaintext highlighter-rouge">MSSTORE_SELLER_ID</code></td>
      <td>凭证</td>
      <td><code class="language-plaintext highlighter-rouge">gh secret set &lt;NAME&gt; --env store-submission --repo &lt;owner&gt;/&lt;repo&gt;</code>，终端交互输入，不经过对话历史</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">MSSTORE_PRODUCT_ID</code></td>
      <td>公开标识（跟 iOS/macOS 把 bundle id 直接写死一样）</td>
      <td><code class="language-plaintext highlighter-rouge">gh variable set MSSTORE_PRODUCT_ID --env store-submission --body "&lt;productId&gt;"</code>，直接写，不用交互</td>
    </tr>
  </tbody>
</table>

<p>没什么好说的，4 个 Secret 自己在终端里敲，<code class="language-plaintext highlighter-rouge">PRODUCT_ID</code> 因为不是凭证直接用 <code class="language-plaintext highlighter-rouge">gh variable set</code> 写进去就行。
另外我将所有 KV 都写入了 store-submission 环境，具体放在 repo 全局还是环境都可以。</p>

<h2 id="5-ci-落地">5. CI 落地</h2>

<p>落地成 composite action + reusable workflow，方便跟仓库里现有的几个发布 job 拼装。</p>

<p><strong>新增 composite action <code class="language-plaintext highlighter-rouge">build_windows_store</code></strong>：复用 <code class="language-plaintext highlighter-rouge">setup_flutter</code>，构建命令直接调本机开发者也会跑的 <a href="https://github.com/FriesI23/mhabit/blob/main/scripts/build_msix_store.cmd"><code class="language-plaintext highlighter-rouge">scripts/build_msix_store.cmd</code></a>，把 Store 身份参数（<code class="language-plaintext highlighter-rouge">--identity-name</code>/<code class="language-plaintext highlighter-rouge">--publisher</code> 等）集中写在脚本里，避免在 action 和脚本之间重复一份。<code class="language-plaintext highlighter-rouge">--store</code> 模式下 msix 不需要本地签名（Microsoft Store 提交时会自行重新签名），所以这个 action 没有签名相关的 input，跟 <code class="language-plaintext highlighter-rouge">build_windows</code>（GitHub Release 用，要 PFX 签名）完全独立。</p>

<p><strong>新增 <code class="language-plaintext highlighter-rouge">_submit-windows-store.yml</code></strong>：<code class="language-plaintext highlighter-rouge">workflow_call</code> + <code class="language-plaintext highlighter-rouge">dry_run</code> 输入，<code class="language-plaintext highlighter-rouge">environment: store-submission</code>。用官方 <code class="language-plaintext highlighter-rouge">microsoft/microsoft-store-apppublisher</code> action 装 CLI、<code class="language-plaintext highlighter-rouge">reconfigure</code> 写认证，然后是后面”坑”里提到的草稿归属校验（<a href="https://github.com/FriesI23/mhabit/blob/main/scripts/msstore/check_draft_ownership.ps1"><code class="language-plaintext highlighter-rouge">check_draft_ownership.ps1</code></a>）——校验通过才会触发耗时的 Windows 构建，没必要为一个不属于这条 CI 的草稿白跑一次构建。<code class="language-plaintext highlighter-rouge">publish</code> 永远带 <code class="language-plaintext highlighter-rouge">--noCommit</code>，是否真正执行 <code class="language-plaintext highlighter-rouge">submission publish</code> 完全交给 <code class="language-plaintext highlighter-rouge">dry_run</code> 控制。最后一步用后面”坑”里那个细粒度 PAT，把这次的 submission ID 写回 Environment Variable，留给下一次 CI 跑时做归属校验。</p>

<p><strong>修改 <code class="language-plaintext highlighter-rouge">release-app.yml</code></strong>：加一个跟 <code class="language-plaintext highlighter-rouge">build-windows-msix</code> 并行的 <code class="language-plaintext highlighter-rouge">submit-windows-store</code> job，只 <code class="language-plaintext highlighter-rouge">needs: pre-build</code>，不进 <code class="language-plaintext highlighter-rouge">post-build</code> 的 <code class="language-plaintext highlighter-rouge">needs</code> 链。另外加了一条 <code class="language-plaintext highlighter-rouge">if</code>：pre-release 的 tag 直接跳过——Microsoft Store 没有公开的 beta 渠道（package flights/私有受众都是邀请制），pre-release 没有地方可提交。</p>

<p><strong>修改 <code class="language-plaintext highlighter-rouge">manual-submit.yml</code></strong>：加 <code class="language-plaintext highlighter-rouge">windows</code> 输入（<code class="language-plaintext highlighter-rouge">skip</code>/<code class="language-plaintext highlighter-rouge">submit</code> 二选一，没有 track 概念）和对应 job，直接调用，不需要 <code class="language-plaintext highlighter-rouge">resolve-apple-store-track</code> 那一套。</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># _submit-windows-store.yml (part)</span>
<span class="na">on</span><span class="pi">:</span>
  <span class="na">workflow_call</span><span class="pi">:</span>
    <span class="na">inputs</span><span class="pi">:</span>
      <span class="na">dry_run</span><span class="pi">:</span>
        <span class="na">type</span><span class="pi">:</span> <span class="s">boolean</span>
        <span class="na">default</span><span class="pi">:</span> <span class="no">false</span>

<span class="na">jobs</span><span class="pi">:</span>
  <span class="na">submit-windows-store</span><span class="pi">:</span>
    <span class="na">runs-on</span><span class="pi">:</span> <span class="s">windows-2022</span>
    <span class="na">environment</span><span class="pi">:</span> <span class="s">store-submission</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@v6</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Setup Microsoft Store Developer CLI</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">microsoft/microsoft-store-apppublisher@v1.3</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Configure Microsoft Store Developer CLI</span>
        <span class="na">shell</span><span class="pi">:</span> <span class="s">pwsh</span>
        <span class="na">env</span><span class="pi">:</span>
          <span class="na">MSSTORE_TENANT_ID</span><span class="pi">:</span> <span class="s">${{ secrets.MSSTORE_TENANT_ID }}</span>
          <span class="na">MSSTORE_SELLER_ID</span><span class="pi">:</span> <span class="s">${{ secrets.MSSTORE_SELLER_ID }}</span>
          <span class="na">MSSTORE_CLIENT_ID</span><span class="pi">:</span> <span class="s">${{ secrets.MSSTORE_CLIENT_ID }}</span>
          <span class="na">MSSTORE_CLIENT_SECRET</span><span class="pi">:</span> <span class="s">${{ secrets.MSSTORE_CLIENT_SECRET }}</span>
        <span class="na">run</span><span class="pi">:</span> <span class="pi">&gt;</span>
          <span class="s">msstore reconfigure</span>
          <span class="s">--tenantId $env:MSSTORE_TENANT_ID</span>
          <span class="s">--sellerId $env:MSSTORE_SELLER_ID</span>
          <span class="s">--clientId $env:MSSTORE_CLIENT_ID</span>
          <span class="s">--clientSecret $env:MSSTORE_CLIENT_SECRET</span>
      <span class="c1"># 校验当前 Partner Center 上 pending 的草稿是不是上一次 CI 自己创建</span>
      <span class="c1"># 的，不是就跳过，避免覆盖人工在 Partner Center 准备中的草稿。</span>
      <span class="pi">-</span> <span class="na">id</span><span class="pi">:</span> <span class="s">ownership</span>
        <span class="na">name</span><span class="pi">:</span> <span class="s">Check pending submission ownership</span>
        <span class="na">shell</span><span class="pi">:</span> <span class="s">pwsh</span>
        <span class="na">run</span><span class="pi">:</span> <span class="pi">&gt;</span>
          <span class="s">scripts\msstore\check_draft_ownership.ps1</span>
          <span class="s">-ProductId "${{ vars.MSSTORE_PRODUCT_ID }}"</span>
          <span class="s">-KnownSubmissionId "${{ vars.MSSTORE_LAST_CI_SUBMISSION_ID }}"</span>
      <span class="pi">-</span> <span class="na">id</span><span class="pi">:</span> <span class="s">build</span>
        <span class="na">if</span><span class="pi">:</span> <span class="s">${{ steps.ownership.outputs.proceed == 'true' }}</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">./.github/actions/build_windows_store</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Stage package</span>
        <span class="na">if</span><span class="pi">:</span> <span class="s">${{ steps.ownership.outputs.proceed == 'true' }}</span>
        <span class="na">shell</span><span class="pi">:</span> <span class="s">pwsh</span>
        <span class="na">env</span><span class="pi">:</span>
          <span class="na">MSIX_PATH</span><span class="pi">:</span> <span class="s">${{ steps.build.outputs.msix-path }}</span>
        <span class="na">run</span><span class="pi">:</span> <span class="s">msstore publish $env:MSIX_PATH -id ${{ vars.MSSTORE_PRODUCT_ID }} --noCommit</span>
      <span class="pi">-</span> <span class="na">id</span><span class="pi">:</span> <span class="s">changelog</span>
        <span class="na">name</span><span class="pi">:</span> <span class="s">Stage changelog metadata</span>
        <span class="na">if</span><span class="pi">:</span> <span class="s">${{ steps.ownership.outputs.proceed == 'true' }}</span>
        <span class="na">shell</span><span class="pi">:</span> <span class="s">pwsh</span>
        <span class="na">run</span><span class="pi">:</span> <span class="pi">&gt;</span>
          <span class="s">scripts\msstore\update_changelog.ps1</span>
          <span class="s">-ProductId "${{ vars.MSSTORE_PRODUCT_ID }}"</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Finalize submission</span>
        <span class="na">if</span><span class="pi">:</span> <span class="s">${{ steps.ownership.outputs.proceed == 'true' &amp;&amp; !inputs.dry_run }}</span>
        <span class="na">shell</span><span class="pi">:</span> <span class="s">pwsh</span>
        <span class="na">run</span><span class="pi">:</span> <span class="s">msstore submission publish ${{ vars.MSSTORE_PRODUCT_ID }}</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Record CI-owned submission Id</span>
        <span class="na">if</span><span class="pi">:</span> <span class="s">${{ steps.ownership.outputs.proceed == 'true' }}</span>
        <span class="na">shell</span><span class="pi">:</span> <span class="s">pwsh</span>
        <span class="na">env</span><span class="pi">:</span>
          <span class="na">GH_TOKEN</span><span class="pi">:</span> <span class="s">${{ secrets.MSSTORE_VARS_PAT }}</span>
        <span class="na">run</span><span class="pi">:</span> <span class="pi">&gt;</span>
          <span class="s">gh variable set MSSTORE_LAST_CI_SUBMISSION_ID</span>
          <span class="s">--body "${{ steps.changelog.outputs.submission-id }}"</span>
          <span class="s">--env store-submission</span>
          <span class="s">--repo ${{ github.repository }}</span>
</code></pre></div></div>

<h2 id="坑">坑</h2>

<p>代码写完、真实 CI 跑起来之后，又踩了几个一开始没预料到的坑。下面按时间顺序记录。</p>

<h3 id="setup_flutter-在-cmd-步骤里找不到-flutterdart">setup_flutter 在 cmd 步骤里找不到 flutter/dart</h3>

<p>根因很简单但藏得很深：共享 action <code class="language-plaintext highlighter-rouge">setup_flutter</code> 把 <code class="language-plaintext highlighter-rouge">"&lt;目录&gt;:$PATH"</code> 整行写进了 <code class="language-plaintext highlighter-rouge">$GITHUB_PATH</code>。bash 步骤”凑巧”能工作（bash 按 <code class="language-plaintext highlighter-rouge">:</code> 拆 <code class="language-plaintext highlighter-rouge">PATH</code>），但 Windows <code class="language-plaintext highlighter-rouge">cmd</code> 步骤只认 <code class="language-plaintext highlighter-rouge">;</code> 作为路径分隔符，整行 <code class="language-plaintext highlighter-rouge">:</code> 直接被当成目录名的一部分，自然找不到 <code class="language-plaintext highlighter-rouge">flutter</code>/<code class="language-plaintext highlighter-rouge">dart</code>。</p>

<p>之前四个 <code class="language-plaintext highlighter-rouge">build_*</code> action 全部用的 bash 步骤，这个 bug 从来没暴露过——Windows Store 这条是第一个用 <code class="language-plaintext highlighter-rouge">cmd</code> 步骤跑裸 <code class="language-plaintext highlighter-rouge">flutter</code>/<code class="language-plaintext highlighter-rouge">dart</code> 命令的消费方。修法也简单：</p>

<p>只写目录本身（去掉 <code class="language-plaintext highlighter-rouge">:$PATH</code>），对其余四个调用无影响。</p>

<h3 id="msstore-cli-输出的-json-不能直接-convertfrom-json">msstore CLI 输出的 JSON 不能直接 ConvertFrom-Json</h3>

<p><code class="language-plaintext highlighter-rouge">msstore submission get</code> 会把 JSON 当人类可读的文本来打印——按终端列宽强制换行。如果输出里有长的中文字段（编码成 <code class="language-plaintext highlighter-rouge">\uXXXX</code> 转义），换行边界刚好切到转义序列中间，PowerShell 把多行拼回去再 <code class="language-plaintext highlighter-rouge">ConvertFrom-Json</code> 就会报 <code class="language-plaintext highlighter-rouge">Invalid Unicode escape sequence</code>。</p>

<p>本机用同一个真实账号能稳定复现一模一样的截断位置，确认不是网络波动或 CI 特有。修法是截取第一个 <code class="language-plaintext highlighter-rouge">{</code> 到最后一个 <code class="language-plaintext highlighter-rouge">}</code> 之间，再去掉所有原始换行——JSON 在 token 之间本来就不在乎空白，真正的换行应该被编码成 <code class="language-plaintext highlighter-rouge">\n</code> 两个字符，不会是裸换行。</p>

<pre><code class="language-pwsh"># 示意：截取 { 到 } 之间再去除裸换行
# $raw = msstore submission get ...
# $json = ($raw -join '') -replace '...'
</code></pre>

<p>这种设计缺陷在非 ASCII 内容上几乎是必现的。</p>

<h3 id="官方文档摘要的字段名是错的">官方文档摘要的字段名是错的</h3>

<p>网上摘要写的是 <code class="language-plaintext highlighter-rouge">listings[language].whatsNew</code>，但真实账号读出来的是 <code class="language-plaintext highlighter-rouge">Listings.&lt;lang&gt;.BaseListing.ReleaseNotes</code>（legacy API 的 PascalCase 形状）。</p>

<h3 id="草稿归属标记走了三个方案才落地">草稿归属标记走了三个方案才落地</h3>

<p>CI 需要一种方式来判断”当前线上是否已经有本次构建对应的草稿”，避免重复创建。这个标记存在哪里，前后试了三个方案：</p>

<table>
  <thead>
    <tr>
      <th>方案</th>
      <th>结果</th>
      <th>放弃/采用原因</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>写进 <code class="language-plaintext highlighter-rouge">FriendlyName</code></td>
      <td>放弃</td>
      <td>服务端只读/自动生成，写入被静默丢弃（靠真实调一次 <code class="language-plaintext highlighter-rouge">updateMetadata</code> 再读回来才发现，不是看文档看出来的）</td>
    </tr>
    <tr>
      <td>写进 <code class="language-plaintext highlighter-rouge">Description</code></td>
      <td>放弃</td>
      <td>技术上可写，但这是真实展示给 Store 用户的商品描述文案，提交链路没有人工检查点，一旦标记没清理干净就会被真实发布出去</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">store-submission</code> Environment 下的固定 Variable</td>
      <td><strong>采用</strong></td>
      <td>不读写 Submission 任何字段；中途也试过 <code class="language-plaintext highlighter-rouge">actions/cache</code>，但 cache 7 天不用会被清理，且清理后的误判不会自动恢复，等于 CI 卡死等人工，因此最后使用 Variable 来存储数据</td>
    </tr>
  </tbody>
</table>

<p>这里有个方法论上的教训：”字段技术上可写” ≠ “适合用来存自动化标记”——选状态存储位置时，要先确认<strong>谁会看到这个字段</strong>，而不是只确认能不能写进去。<code class="language-plaintext highlighter-rouge">Description</code> 就是典型的反例：能写，但用户可见，风险太高。</p>

<p>最终落地为两个 PowerShell 脚本：</p>

<ul>
  <li><a href="https://github.com/FriesI23/mhabit/blob/main/scripts/msstore/check_draft_ownership.ps1"><code class="language-plaintext highlighter-rouge">check_draft_ownership.ps1</code></a> 负责比对当前草稿 ID 与已知的 CI 归属标记</li>
  <li><a href="https://github.com/FriesI23/mhabit/blob/main/scripts/msstore/update_changelog.ps1"><code class="language-plaintext highlighter-rouge">update_changelog.ps1</code></a> 负责写入 changelog 并回写归属标记</li>
</ul>

<p>两个脚本都可以脱离 CI 在本机直接跑，跟 fastlane lane 的用法一致。</p>

<h3 id="细粒度-pat-第一次配的权限类别选错了">细粒度 PAT 第一次配的权限类别选错了</h3>

<p><code class="language-plaintext highlighter-rouge">gh variable set --env store-submission ...</code> 需要 PAT 有写入权限。第一次配的时候以为勾上 “Variables: read and write” 就够了，结果真实跑直接报 403。查了文档才发现：<strong>Environment 级</strong>的 Variable 接口（带 <code class="language-plaintext highlighter-rouge">--env</code> 参数那种）实际归在 <strong>“Environments”</strong> 这个权限类别下，跟仓库/组织级 Variable 用的 “Variables” 类别是两个独立的勾选项——这个分类非常不直观，很容易选错而不自知。把 “Environments” 也勾上（保留原来的 “Variables”，两个都给 Read and write）之后才跑通。</p>

<p><img src="https://cdn.jsdelivr.net/gh/FriesI23/blog-image@master/img/flutter-windows-store-submission-automation-08.png" alt="细粒度 PAT 权限选择页面：Environments 与 Variables 是两个独立的勾选项，都需要 Read and write" width="800" /></p>

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

<p>最终整体流程其实就是<em>账号配置 → CLI 认证 → 写 Environment → CI 落地</em>这四步，账号部分卡住的点基本都在第一步，代码部分的坑全在最后一步，中间两步反而是最顺利的。</p>

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

<ol>
  <li><a href="https://github.com/microsoft/msstore-cli">msstore-cli—Microsoft Store CLI</a></li>
  <li><a href="https://learn.microsoft.com/en-us/entra/identity-platform/quickstart-register-app">Quickstart: Register an application—Microsoft identity platform</a></li>
  <li><a href="https://learn.microsoft.com/en-us/partner-center/enroll/service-principal">Microsoft Entra service principal—Partner Center</a></li>
  <li><a href="https://learn.microsoft.com/en-us/azure/cost-management-billing/manage/microsoft-entra-id-free">Microsoft Entra ID Free—Microsoft Cost Management</a></li>
  <li><a href="https://learn.microsoft.com/en-us/windows/apps/publish/partner-center/associate-existing-azure-ad-tenant-with-partner-center-account">Associate an existing Microsoft Entra ID tenant with your Partner Center account</a></li>
</ol>

<!-- refs -->]]></content><author><name>Fries_I23</name></author><category term="deploy" /><category term="flutter" /><category term="github-action" /><category term="windows" /><category term="microsoft-store" /><category term="ci/cd" /><summary type="html"><![CDATA[最近给 Flutter 应用接入了 Microsoft Store 的自动化提交流程， 本以为账号配置会是个一次性的体力活，结果 Partner Center + Entra ID 这一侧就踩了几个坑，落地 CI 之后又冒出来几个更隐蔽的问题， 因此在这里记录一下。]]></summary></entry><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>MoE</td>
      <td>~12 GB</td>
      <td>~3.6B</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>MoE</td>
      <td>22 GB</td>
      <td>~3B</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></feed>