<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:cc="http://cyber.law.harvard.edu/rss/creativeCommonsRssModule.html">
    <channel>
        <title><![CDATA[彼得潘的 Swift iOS / Flutter App 開發教室 - Medium]]></title>
        <description><![CDATA[學習 Swift iOS / Flutter App 開發的學生作品集 - Medium]]></description>
        <link>https://medium.com/peter-pan-swift-ios-class?source=rss----863d0bb9772b---4</link>
        <image>
            <url>https://cdn-images-1.medium.com/proxy/1*TGH72Nnw24QL3iV9IOm4VA.png</url>
            <title>彼得潘的 Swift iOS / Flutter App 開發教室 - Medium</title>
            <link>https://medium.com/peter-pan-swift-ios-class?source=rss----863d0bb9772b---4</link>
        </image>
        <generator>Medium</generator>
        <lastBuildDate>Fri, 13 Nov 2026 11:29:29 GMT</lastBuildDate>
        <atom:link href="https://medium.com/feed/peter-pan-swift-ios-class" rel="self" type="application/rss+xml"/>
        <webMaster><![CDATA[yourfriends@medium.com]]></webMaster>
        <atom:link href="http://medium.superfeedr.com/" rel="hub"/>
        <item>
            <title><![CDATA[[彼得潘UIKit作業8]計算TDEE]]></title>
            <link>https://medium.com/peter-pan-swift-ios-class/%E5%BD%BC%E5%BE%97%E6%BD%98uikit%E4%BD%9C%E6%A5%AD8-%E8%A8%88%E7%AE%97tdee-b0d69b5007cb?source=rss----863d0bb9772b---4</link>
            <guid isPermaLink="false">https://medium.com/p/b0d69b5007cb</guid>
            <dc:creator><![CDATA[Ting]]></dc:creator>
            <pubDate>Sat, 17 Oct 2026 07:45:23 GMT</pubDate>
            <atom:updated>2026-10-17T07:45:22.508Z</atom:updated>
            <content:encoded><![CDATA[<p><a href="https://medium.com/%E5%BD%BC%E5%BE%97%E6%BD%98%E7%9A%84%E8%A9%A6%E7%85%89-%E5%8B%87%E8%80%85%E7%9A%84-100-%E9%81%93-swift-ios-app-%E8%AC%8E%E9%A1%8C/233-%E7%B5%90%E5%90%88%E5%90%84%E7%A8%AE%E8%BC%B8%E5%85%A5%E5%85%83%E4%BB%B6-%E9%A1%AF%E7%A4%BA-alert-%E7%9A%84-swiftui-uikit-flutter-app-0b742250143d">#233 結合各種輸入元件 &amp; 顯示 alert 的 SwiftUI / UIKit / Flutter App</a></p><p>這次作業會用到許多輸入元件，將使用者輸入的資料計算後再顯示結果，所以我就想說來做看看先前減脂時有用過的計算每日總熱量消耗(TDEE)的網站，網站如下：</p><p><a href="https://tools.heho.com.tw/bmr/">基礎代謝率BMR計算機｜TDEE計算｜健康小幫手</a></p><p><strong>BMRViewController 的 storyboard 畫面</strong></p><p>要計算 TDEE 前，必須先算出每個人的基礎代謝率(BMR)，所以先把會用到的 UI 元件拉一拉。</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*YphhAFobnH3cThDE9Perkg.png" /></figure><p>生理性別我本來在猶豫要用 Pop Up Button 或是 Pull Down Button ；查完資料後，由於性別只有男跟女，且選完後也不需要再做其他動作，所以我最後使用 Pop Up Button。</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*yxkA9P0mERPIM6b4muSJxw.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*4f_e0jlcrCoMeElZ7pfwHg.png" /></figure><p><strong>生理性別的選單</strong></p><p>性別圖案使用 SF Symbol，當使用者點選某個選項時，將 property gender 設為所選的性別，方便判斷之後計算 BMR 要用的公式(男女的算法不同)。</p><p><a href="https://medium.com/%E5%BD%BC%E5%BE%97%E6%BD%98%E7%9A%84-swift-ios-app-%E9%96%8B%E7%99%BC%E5%95%8F%E9%A1%8C%E8%A7%A3%E7%AD%94%E9%9B%86/ios-%E7%9A%84%E9%81%B8%E5%96%AE-menu-%E6%8C%89%E9%88%95-pull-down-button-pop-up-button-2ddab2181ee5">iOS 的選單(menu)按鈕 — Pull Down Button &amp; Pop Up Button</a></p><pre>var gender = Gender.male // 計算BMR時，男女生算法不同，預設為男生<br><br>override func viewDidLoad() {<br>    super.viewDidLoad()<br>        <br>    // 設定生理性別的選項清單<br>    let menu = UIMenu(children: [<br>        UIAction(title: &quot;男性&quot;, image: UIImage(systemName: &quot;figure.teen&quot;), handler: { _ in<br>            self.gender = .male<br>            self.updateUI()<br>        }),<br>        UIAction(title: &quot;女性&quot;, image: UIImage(systemName: &quot;figure.stand.dress&quot;), handler: { _ in<br>            self.gender = .female<br>            self.updateUI()<br>        })<br>    ])<br>    genderMenuButton.menu = menu<br>    }</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ilyt9szNTpIMQejtBZJGyg.png" /></figure><p><strong>年齡的 TextField 和 Stepper 同步問題</strong></p><p>在 ageTextField 輸入年齡後，點 stepper 調整，發現數值不對。假設我年齡輸入 20，點 + 時，年齡會變 1。必須在輸入完年齡後，點擊其他輸入框，再點選 stepper 才會是正確的。</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*OB0CPkaJx1o8Iz0fncFP7g.gif" /></figure><p>檢查以後發現問題是 UI 元件預設的 action event 造成的。</p><p><a href="https://medium.com/%E5%BD%BC%E5%BE%97%E6%BD%98%E7%9A%84-swift-ios-app-%E9%96%8B%E7%99%BC%E5%95%8F%E9%A1%8C%E8%A7%A3%E7%AD%94%E9%9B%86/ui-%E5%85%83%E4%BB%B6%E9%A0%90%E8%A8%AD%E7%9A%84-action-event-db451c158b1d">UI 元件預設的 action event</a></p><p>在將 ageTextField 拉 IBAction 時，textField 預設是 Editing Did End，function 將在使用者結束 textField 的文字輸入後，游標離開 textField 時才會觸發。</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*pH20hcavBCgqHnqdA4dBYg.png" /></figure><p>所以我們必須將 Event 改成 Editing Changed。</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*f1Lb2GvXX6Or9WQ6hj55pg.jpeg" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*BdYbo6iev-kLN77QaQ-xfw.gif" /></figure><p><strong>主動判斷使用者輸入的資訊是否正確</strong></p><p>我一開始把三個 textField 都拉到同一個 IBAction updateUI，把 Event 都改成 Editing Changed；並把性別的 Pop Up Button 也拉到同一個 IBAction，結果發現當輸入正確且有計算出 BMR 時，如果我直接切換性別，BMR並不會重新計算！</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*R5Vhd6YYT6CyXVZvmcZwxA.gif" /></figure><p>後來問了 AI 後，才找到答案。</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*M_Ita6fl-1hOvhubaVZnOw.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*3GggRZhfU4S3q2fAiZcOzg.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*kcQKshg5hHSchj3JnwtedA.png" /></figure><p>為了在 UIMenu 可以呼叫 updateUI，所以把 updateUI 獨立出來成為一個 function，而三個 textField 再另外拉一個 IBAction inputDidChanged，從裡面呼叫 function updateUI。</p><pre>// 設定生理性別的選項清單<br>let menu = UIMenu(children: [<br>    UIAction(title: &quot;男性&quot;, image: UIImage(systemName: &quot;figure.teen&quot;), handler: { _ in<br>        self.gender = .male<br>        self.updateUI()<br>    }),<br>    UIAction(title: &quot;女性&quot;, image: UIImage(systemName: &quot;figure.stand.dress&quot;), handler: { _ in<br>        self.gender = .female<br>        self.updateUI()<br>    })<br>])<br>genderMenuButton.menu = menu</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*NOsE-O1j41-3KIIS70lePg.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*YBN2SmnGkbM7RQRoiLd0yg.gif" /></figure><p>最後，BMRViewController 的架構大致上如圖所示：</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*bxYekXciZ1t3pb_eDOl2OA.png" /></figure><p><strong>讓運動天數的 UISlider 數值變整數</strong></p><p><a href="https://medium.com/%E5%BD%BC%E5%BE%97%E6%BD%98%E7%9A%84-swift-ios-app-%E9%96%8B%E7%99%BC%E5%95%8F%E9%A1%8C%E8%A7%A3%E7%AD%94%E9%9B%86/%E8%AE%93-slider-%E7%9A%84%E6%95%B8%E5%80%BC%E8%AE%8A%E6%88%90%E5%9B%BA%E5%AE%9A%E6%95%B8%E9%87%8F%E7%9A%84%E6%95%B4%E6%95%B8-e2ef715e3591">讓 UISlider 的數值變成固定數量的整數</a></p><pre>@IBAction func exerciseDaySliderChanged(_ sender: UISlider) {<br>    // 將 slider 的 value 四捨五入<br>    sender.value.round()<br>    // 因為 value 型別為 Float，利用 Int 變為整數再顯示，才不會有.0<br>    exerciseDayLabel.text = Int(sender.value).description + &quot;天&quot;<br>}</pre><p><strong>TDEEViewController 的 storyboard 畫面</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*R1KTv2V1_BQfRRRRS3d58w.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*1ILYzmBT8nHAQMnOrvQwKw.png" /></figure><p>跟前ㄧ頁的 BMRViewController 很像，都是要先判斷使用者所選的選項，再去顯示相對應的圖片、說明、計算公式和 TDEE 的計算結果等資訊。</p><p><strong>將要顯示的多個資訊包成一個 struct TDEEInfo</strong></p><pre>struct TDEEInfo {<br>    let activityImage: String<br>    let activityLevel: String<br>    let exerciseDayInfo: String<br>    let bmrFormula: String<br>    let multiplier: Double<br>}</pre><pre>var tdeeInfo = [<br>    TDEEInfo(activityImage: &quot;lv1&quot;, activityLevel: &quot;身體活動趨於靜態&quot;, exerciseDayInfo: &quot;(幾乎不運動)&quot;, bmrFormula: &quot;BMR x 1.2 = &quot;, multiplier: 1.2),<br>    TDEEInfo(activityImage: &quot;lv2&quot;, activityLevel: &quot;身體活動程度較低&quot;, exerciseDayInfo: &quot;(每週運動 1-3 天)&quot;, bmrFormula: &quot;BMR x 1.375 = &quot;, multiplier: 1.375),<br>    TDEEInfo(activityImage: &quot;lv3&quot;, activityLevel: &quot;身體活動程度正常&quot;, exerciseDayInfo: &quot;(每週運動 4-5 天)&quot;, bmrFormula: &quot;BMR x 1.55 = &quot;, multiplier: 1.55),<br>    TDEEInfo(activityImage: &quot;lv4&quot;, activityLevel: &quot;身體活動程度較高&quot;, exerciseDayInfo: &quot;(每週運動 6-7 天)&quot;, bmrFormula: &quot;BMR x 1.72 = &quot;, multiplier: 1.72),<br>    TDEEInfo(activityImage: &quot;lv5&quot;, activityLevel: &quot;身體活動程度激烈&quot;, exerciseDayInfo: &quot;(長時間運動或體力勞動工作)&quot;, bmrFormula: &quot;BMR x 1.9 = &quot;, multiplier: 1.9)<br>]</pre><p>這樣就不需要宣告一堆陣列，以免因 index 輸入錯誤，造成顯示錯誤資訊等問題。</p><p><strong>TDEEViewController 架構</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*AdzUz9C_J9qWfispBDTahQ.png" /></figure><p>每次選項更改時，會觸發 IBAction selectionsChanged，呼叫 function updateTDEEUI 去檢查使用者所選的選項為何，再依選項呼叫 function showTDEE 將 TDEEInfo 的所有資訊都顯示在畫面上。</p><pre>func updateTDEEUI() {<br>    // 判斷是否要減重<br>    if loseWeightSegmentedControl.selectedSegmentIndex == 0 {<br>        isLoseWeight = false<br>    } else {<br>        isLoseWeight = true<br>    }<br>    // 判斷每週運動幾天<br>    exerciseDay = Int(exerciseDaySlider.value)<br>        <br>    // 依不同選項選擇要顯示的TDEEInfo，並呼叫showTDEE去顯示結果<br>    if isLaborSwitch.isOn {<br>        showTDEE(tdee: tdeeInfo[4])<br>    } else {<br>        switch exerciseDay {<br>        case 0:<br>            showTDEE(tdee: tdeeInfo[0])<br>        case 1...3:<br>            showTDEE(tdee: tdeeInfo[1])<br>        case 4...5:<br>            showTDEE(tdee: tdeeInfo[2])<br>        case 6...7:<br>            showTDEE(tdee: tdeeInfo[3])<br>        default:<br>            break<br>        }<br>    }<br>}</pre><pre>func showTDEE(tdee: TDEEInfo) {<br>    // 依使用者所選，顯示對應的 TDEEInfo<br>    <br>    var tdeeResult = Double()<br>    tdeeResult = calculateTDEE(bmrText: self.bmrText, bmrMultiplier: tdee.multiplier, loseWeight: isLoseWeight)<br>    <br>    tdeeImageView.image = UIImage(named: tdee.activityImage)<br>    tdeeLevelLabel.text = tdee.activityLevel<br>    tdeeExerciseDayLabel.text = tdee.exerciseDayInfo<br>    if isLoseWeight {<br>        tdeeFormulaLabel.text = &quot;建議攝取 &quot; + String(format: &quot;%.2f&quot;, tdeeResult)<br>    } else {<br>        tdeeFormulaLabel.text = tdee.bmrFormula + String(format: &quot;%.2f&quot;, tdeeResult)<br>    }<br>}</pre><p><strong>成品</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*kDCtwJsysdcBbyKsMI3s-w.gif" /></figure><p><strong>專案在 GitHub 網址</strong></p><p><a href="https://github.com/star820819/hw8_TDEE/tree/main">GitHub - star820819/hw8_TDEE</a></p><p><strong>心得</strong></p><p>這次作業因為有個網站可以當範例，所以我就照著順序想辦法寫出來，在碰到切換性別而 BMR 結果沒有重新計算的問題時，才發現“不先思考一下架構再寫程式”這件事是多麼的可怕！我先前寫程式時，都是目前需要什麼功能就先把它寫出來，所以很常在往後寫的時候，發現先前寫好的會造成問題，所以又得回頭調整先前所寫的程式，等於我很常改架構。後來照著 AI 的建議重新調整後，Bug 也順利解決掉，因此我寫下一頁的 TDEEViewController 時，我有先想一下架構：當選項被更改時，該怎麼呼叫、流程怎麼走、哪些動作一直在重複做等，所以我寫 TDEEViewController 所花的時間比 BMRViewController 少很多。</p><p>提醒自己：要先確認好需求，想一下架構，再動手寫 code，這樣反而比較快！</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=b0d69b5007cb" width="1" height="1" alt=""><hr><p><a href="https://medium.com/peter-pan-swift-ios-class/%E5%BD%BC%E5%BE%97%E6%BD%98uikit%E4%BD%9C%E6%A5%AD8-%E8%A8%88%E7%AE%97tdee-b0d69b5007cb">[彼得潘UIKit作業8]計算TDEE</a> was originally published in <a href="https://medium.com/peter-pan-swift-ios-class">彼得潘的 Swift iOS / Flutter App 開發教室</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Fedora 44 KDE 變身 macOS Tahoe（下）]]></title>
            <link>https://medium.com/peter-pan-swift-ios-class/fedora-44-kde-macos-tahoe-part-2-theme-b1fd627a818e?source=rss----863d0bb9772b---4</link>
            <guid isPermaLink="false">https://medium.com/p/b1fd627a818e</guid>
            <category><![CDATA[macos]]></category>
            <category><![CDATA[kde]]></category>
            <category><![CDATA[fedora]]></category>
            <category><![CDATA[desktop-customization]]></category>
            <category><![CDATA[linux]]></category>
            <dc:creator><![CDATA[RuRu (Sandy)]]></dc:creator>
            <pubDate>Sat, 03 Oct 2026 11:22:51 GMT</pubDate>
            <atom:updated>2026-10-03T11:22:50.582Z</atom:updated>
            <content:encoded><![CDATA[<h3>Fedora 44 KDE 變身 macOS Tahoe（下）：把 KDE Plasma 打扮成 macOS 介面</h3><p>這篇是從我的部落格同步過來的，中間的廣告是 Medium 平台放的。想看沒有廣告的乾淨版，歡迎來我的部落格閱讀：</p><p><a href="https://swift.moe/zh-hant/fedora-44-kde-macos-tahoe-part-2-theme/">Fedora 44 KDE 變身 macOS Tahoe（下）：把 KDE Plasma 打扮成 macOS 介面</a></p><p>上篇把 Fedora 44 KDE 裝好、調到能安心截圖的狀態，這篇終於要開始換裝，目標是 macOS Tahoe 的樣子。</p><blockquote><a href="https://swift.moe/zh-hant/fedora-44-kde-macos-tahoe-part-1-parallels-install/"><em>Fedora 44 KDE 變身 macOS Tahoe（上）：在 Apple Silicon Mac 用 Parallels 安裝，順便收集一路上的小陷阱</em></a></blockquote><p>改造前的桌面長這樣：底部一條工作列，圖示是 KDE 的 Breeze 風格，視窗按鈕在右邊。桌布上坐在屋頂邊緣的女孩，是我把自己設計的角色放進 Fedora 44 的原廠夜景桌布做出來的，這一篇會一直看到她。</p><figure><img alt="Fedora 44 KDE 預設桌面，底部有工作列，深藍色城市夜景桌布上坐著一位綠色制服的女孩" src="https://cdn-images-1.medium.com/max/1024/1*rLntjmluxzsXY6b6H7q2vA.png" /><figcaption><em>改造前的 Fedora KDE 桌面</em></figcaption></figure><p>除了這張截圖，我也在 Parallels 拍了一張叫「KDE 原廠」的快照。等一下有一步會把整個面板拆掉重建，有快照在，怎麼玩都回得去。</p><h3>先認識要換的零件</h3><p>KDE 的外觀分成好幾層。系統設定裡的 Global Theme 是一次套用整套的「裝潢包」，底下的 Colors、Application Style、Plasma Style、Window Decorations、Icons、Pointers 則是可以單獨更換的零件。頂端選單列和 Dock 在 KDE 裡都叫「面板」，只是擺的位置和放的東西不同。另外還有一個叫 Kvantum 的主題引擎，專門負責傳統 Qt 程式的外觀，等一下它會出場一次。</p><p>主題我選的是 vinceliuice 的 <a href="https://github.com/vinceliuice/MacTahoe-kde">MacTahoe</a> 。同一位作者比較有名的 WhiteSur 模仿的是 Big Sur，最近一次正式發布停在 2024 年底；MacTahoe 是新的作品，模仿 macOS Tahoe，有淺色和深色兩個版本，跟現在的 macOS 長相比較接近。主題只是圖片和設定檔，跟 CPU 架構無關，ARM 版的 Fedora 可以直接用。</p><h3>安裝主題</h3><p>先裝兩個工具，git 用來下載主題，kvantum 是剛剛提到的主題引擎：</p><pre> sudo dnf install git kvantum</pre><p>主題我放在 Linux 自己的資料夾裡。上篇提到的 Mac Downloads 是 Parallels 的共用資料夾，在裡面執行安裝腳本有時會遇到執行權限的問題，所以另外開一個 src 資料夾放：</p><pre>mkdir -p ~/src &amp;&amp; cd ~/src<br>git clone https://github.com/vinceliuice/MacTahoe-kde.git --depth=1<br>cd MacTahoe-kde<br>./install.sh</pre><p>這裡不需要 sudo。主題是裝在自己的家目錄底下，加了 sudo 反而會裝到 root 的家，之後在設定裡怎麼找都找不到。--depth=1 是只下載最新版、不要歷史紀錄，會快很多。</p><p>圖示和游標是另一個套件：</p><pre>cd ~/src<br>git clone https://github.com/vinceliuice/MacTahoe-icon-theme.git --depth=1<br>cd MacTahoe-icon-theme<br>./install.sh<br>cd cursors<br>./install.sh</pre><h3>套用 Global Theme，Fedora 變成 macOS Tahoe</h3><p>回到 System Settings 的 Global Theme，會多出 MacTahoe-Light 和 MacTahoe-Dark 兩個選項。點下去會問要套用哪些東西，有兩個勾選框。Appearance settings 是換皮膚，配色、視窗裝飾、圖示這些；Desktop and window layout 是換骨架，會把現在的面板拆掉，換成作者設計好的頂端選單列加底部 Dock。兩個都勾的話，會出現一個警告。</p><figure><img alt="套用 MacTahoe-Light 的確認視窗，Appearance settings 與 Desktop and window layout 都已勾選，下方有橘色警告" src="https://cdn-images-1.medium.com/max/1024/1*CELL785fjW_32xiJ4zpGNA.png" /><figcaption><em>兩個都勾會警告要拆掉現有面板，這正是我們要的</em></figcaption></figure><p>它拆的只是面板、Dock 和小工具這些家具擺設，檔案、程式和其他系統設定都不會動。按下 Apply，畫面閃一下，桌面就換了一副長相：左上角一顆蘋果，底部一個 Dock，檔案管理員 Dolphin（KDE 裡相當於 Finder 的角色）的圖示也變成 Finder 的笑臉。桌布會被換成主題附的 Tahoe 桌布，從 Pictures 選回自己的就好。</p><figure><img alt="套用 MacTahoe 主題後的 Fedora 桌面，左上角有蘋果圖示的應用程式選單，底部有 Dock" src="https://cdn-images-1.medium.com/max/1024/1*2zEhmGFA3CIknCrPjLVOcA.png" /><figcaption><em>套用後的第一眼，蘋果選單和 Dock 都出現了</em></figcaption></figure><h3>Kvantum 忘了選主題，視窗一半白一半灰</h3><p>打開系統設定的其他頁面時，我遇到了一個奇怪的畫面：左邊側欄和標題列是白的，右邊的內容區卻是深灰色。</p><figure><img alt="KDE 系統設定的 Window Behavior 頁面，左側側欄為淺色，右側設定內容為深灰色" src="https://cdn-images-1.medium.com/max/1024/1*IhdwBts2L7x7rFSXqW7AxQ.png" /><figcaption><em>Kvantum 還沒選主題時，同一個視窗一半白一半灰</em></figcaption></figure><p>原因是 KDE 的介面由兩種技術畫出來。側欄這種新式介面聽 Colors 的話，右邊的傳統設定內容聽 Application Style 的話。Global Theme 把 Application Style 設成了 kvantum，但 Kvantum 自己還要選主題，沒選的話它會用內建的預設主題，而那個預設剛好是深灰色。同一件衣服交給兩個裁縫師，一個拿到白布，另一個沒拿到指示，就從倉庫隨便抓了一塊黑布。</p><p>修法是打開 Kvantum Manager（點蘋果選單，在搜尋框輸入 kvantum），在 Change/Delete Theme 選 MacTahoe，按 Use this theme。已經開著的視窗要關掉重開才會換上新樣式，登出再登入一次最保險。</p><figure><img alt="Kvantum Manager 視窗，Select a theme 選擇 MacTahoe，下方狀態列顯示 Theme changed to MacTahoe" src="https://cdn-images-1.medium.com/max/1024/1*tcUcROlSQ3S7ZOP8Bebj5g.png" /><figcaption><em>Kvantum Manager 裡選 MacTahoe，兩邊的顏色就一致了</em></figcaption></figure><h3>紅綠燈搬到左邊</h3><p>套完主題，視窗按鈕變成了圓形，但還是在右邊，而且順序是黃、綠、紅。主題只負責按鈕長怎樣，放哪裡是另一個設定。</p><p>在 Window Decorations 頁面，這個設定藏在右上角的三個點選單裡，叫 Configure Titlebar Buttons。</p><figure><img alt="KDE 系統設定的 Window Decorations 頁面，右上角選單展開，顯示 Configure Titlebar Buttons 選項" src="https://cdn-images-1.medium.com/max/1024/1*GN2ZxtEmcExeOogt4sluag.png" /><figcaption><em>按鈕排列的設定藏在右上角的三個點裡</em></figcaption></figure><p>進去是一個拖拉編輯器，上面是標題列的預覽，下面是可用的按鈕。預設左邊放了視窗選單、釘選和一個「從截圖中隱藏」的按鈕，右邊是說明、縮小、放大、關閉。編輯器裡用的是通用圖示，叉叉是關閉，也就是紅色那顆。</p><figure><img alt="Titlebar Buttons 編輯器，標題列預覽左側有三個功能按鈕，右側依序為說明、縮小、放大、關閉" src="https://cdn-images-1.medium.com/max/1024/1*DmJSnB26ba_6-EWwq-9fnQ.png" /><figcaption><em>預設的按鈕排列，關閉在最右邊</em></figcaption></figure><p>把左邊三顆和右邊的說明拖到下方移除，再把關閉、縮小、放大依序拖到最左邊，按 Apply。</p><figure><img alt="Titlebar Buttons 編輯器，標題列預覽左側依序為關閉、縮小、放大，右側為空" src="https://cdn-images-1.medium.com/max/1024/1*N6LBamOTPwb-e-TeBnjSbQ.png" /><figcaption><em>改成關閉、縮小、放大在左邊</em></figcaption></figure><p>套用後有個小細節很值得一提：只有最前面的視窗才會顯示紅黃綠，後面的視窗按鈕都是灰色的，跟 macOS 一樣。主題作者連這個都做了。</p><h3>選單列和 Dock 的大小</h3><p>作者預設的頂端選單列有點厚，Dock 又偏小。兩個都是面板，所以調法一樣：在面板空白處按右鍵選 Show Panel Configuration，旁邊會出現 Panel Settings。</p><p>選單列我把 Panel Height 調到 32，Width 維持 Fill width 讓它橫跨整個螢幕，Visibility 是 Always visible，Opacity 用 Translucent，會透出一點桌布的顏色。</p><figure><img alt="KDE 編輯模式下頂端選單列的 Panel Settings，Panel Height 為 32，Width 為 Fill width，Opacity 為 Translucent" src="https://cdn-images-1.medium.com/max/1024/1*Sza8gkmVwnsLwfP1SKi2wQ.png" /><figcaption><em>頂端選單列的 Panel Height 調到 32</em></figcaption></figure><p>Dock 則反過來把 Panel Height 拉到 70，圖示會跟著面板高度一起放大。Width 選 Fit content，Dock 才會像 macOS 那樣只有圖示那麼寬、置中浮在底部；Visibility 是 Dodge windows，視窗碰到它時會自動讓開；Opacity 用 Adaptive。</p><figure><img alt="KDE 編輯模式下 Dock 的 Panel Settings，Panel Height 為 70，Width 為 Fit content，Visibility 為 Dodge windows" src="https://cdn-images-1.medium.com/max/1024/1*gZQnR0ON2T3J1keVU1VCkg.png" /><figcaption><em>Dock 的 Panel Height 拉到 70，Width 設成 Fit content</em></figcaption></figure><p>數字不用照抄，我是把 Mac 本機的桌面和 Parallels 視窗並排看，選單列調到差不多薄，Dock 大約是選單列的兩倍高。</p><h3>Global Menu，選單列的靈魂</h3><p>到這裡外觀已經很像了，但頂端選單列除了蘋果和系統匣之外是空的。macOS 最招牌的，是程式的選單會出現在螢幕最上面。</p><p>在頂端選單列按右鍵選 Add or Manage Widgets，搜尋 Global Menu，把它拖到蘋果的右邊。</p><figure><img alt="KDE 編輯模式下的 Widgets 面板，搜尋 global 找到 Global Menu 小工具，頂端選單列的蘋果圖示旁已放上 Global Menu" src="https://cdn-images-1.medium.com/max/1024/1*JibDH_CbPRZzBWrFkepLag.png" /><figcaption><em>在小工具清單搜尋 Global Menu，拖到頂端選單列的蘋果旁邊</em></figcaption></figure><p>離開編輯模式後打開 Dolphin，File、Edit、View 這些選單就跑到頂端去了，視窗裡原本的選單列也自動消失。</p><figure><img alt="Fedora 桌面頂端的選單列顯示 File、Edit、View 等 Dolphin 選單，下方是沒有選單列的 Dolphin 視窗" src="https://cdn-images-1.medium.com/max/1024/1*ubRWF5KHRECi2hGu75QMxg.png" /><figcaption><em>Dolphin 的選單跑到頂端選單列了</em></figcaption></figure><p>先說一個限制：Firefox 的選單不會跑上去。Global Menu 對 KDE 自家的程式支援最完整，其他程式在 Wayland 上大多不支援，這不是設定錯了。</p><p>另外 Dock 上正在執行的程式，圖示下面會有一個小圓點，這也是 KDE 內建的行為，剛好跟 macOS 一模一樣。</p><h3>晚上自動變深色</h3><p>Global Theme 頁面上方有一個 Switch to Dark Mode at Night 開關，功能就像 macOS 的「外觀：自動」。打開之後會出現 Light 和 Dark 兩張縮圖，這裡要注意一下，它預設選的是 KDE 原本的淺色和深色主題，不是 MacTahoe。要點縮圖分別改成 MacTahoe-Light 和 MacTahoe-Dark，不然天黑時整套 Mac 風介面會被換掉。</p><figure><img alt="KDE 系統設定的 Global Theme 頁面，Switch to Dark Mode at Night 已開啟，下方有 Light 與 Dark 兩張主題縮圖" src="https://cdn-images-1.medium.com/max/1024/1*98jvo7PaLv__bKFKbVbxCQ.png" /><figcaption><em>打開夜間深色模式後，Light 和 Dark 各要指定一次</em></figcaption></figure><p>切換時間在 Configure Day/Night Cycle 裡設定。旁邊那個 Wait until idleness before switching 的意思是時間到了也要等使用者停手幾秒才切換，不會打字打到一半畫面突然變黑。切換過去之後，如果頂端選單列的字還是深色的、在深色桌布上看不清楚，登出再登入一次就好，Plasma Style 要重新登入才會完整換到深色版，字會變成白色。</p><p>還有一件事跟前面 Kvantum 那段有關：Plasma 切換深色時會換配色，但 Kvantum 的主題是獨立的，它不會自動跟著換。如果晚上又出現顏色不一致的畫面，可以到 Kvantum Manager 手動換成 MacTahoeDark，或是把 Application Style 改回 Breeze，它會自動跟著明暗走，代價是元件的 Mac 味少一點。</p><h3>日夜各一張桌布</h3><p>自己設定的桌布不會跟著明暗切換，這點真正的 macOS 也一樣。不過 Plasma 有一個桌布套件的機制，把兩張圖放進同一個套件，淺色模式用一張、深色模式用另一張，Fedora 原廠桌布的日夜版本就是這樣做的。</p><p>我設計了兩張：白天是女孩靠在 Tahoe 的彩帶上，晚上是她抱著膝蓋坐在深藍色浪花的邊緣。</p><figure><img alt="套用 MacTahoe 淺色主題的 Fedora 桌面，藍色與粉紅色的彩帶桌布上坐著綠色制服的女孩" src="https://cdn-images-1.medium.com/max/1024/1*2SrjybH2K4mem6oot4jPQw.png" /><figcaption><em>白天的桌面</em></figcaption></figure><figure><img alt="套用 MacTahoe 深色主題的 Fedora 桌面，深藍色浪花桌布上坐著綠色制服的女孩，上方是紫色夜空" src="https://cdn-images-1.medium.com/max/1024/1*hj3UGfCRpj_dmhkwP5EUaw.png" /><figcaption><em>晚上的桌面</em></figcaption></figure><p>套件的結構是一個 metadata.json 加上 contents/images 和 contents/images_dark 兩個資料夾，圖片檔名要用「寬x高」的格式。手動做有點瑣碎，這裡整理成一段指令，只要把最上面兩行改成自己的檔名，貼進 Konsole 就會自動處理：</p><pre>DAY=&quot;$HOME/Pictures/tfg_fedora_MacTahoe-Light_wallpaper.png&quot;<br>NIGHT=&quot;$HOME/Pictures/tfg_fedora_MacTahoe-Dark_wallpaper.png&quot;<br><br>W=&quot;$HOME/.local/share/wallpapers/TFG-Tahoe&quot;<br>mkdir -p &quot;$W/contents/images&quot; &quot;$W/contents/images_dark&quot;<br>size() { python3 -c &quot;import struct,sys;d=open(sys.argv[1],&#39;rb&#39;).read(24);w,h=struct.unpack(&#39;&gt;II&#39;,d[16:24]);print(f&#39;{w}x{h}&#39;)&quot; &quot;$1&quot;; }<br>cp &quot;$DAY&quot; &quot;$W/contents/images/$(size &quot;$DAY&quot;).png&quot;<br>cp &quot;$NIGHT&quot; &quot;$W/contents/images_dark/$(size &quot;$NIGHT&quot;).png&quot;<br>cat &gt; &quot;$W/metadata.json&quot; &lt;&lt; &#39;EOF&#39;<br>{&quot;KPlugin&quot;: {&quot;Id&quot;: &quot;TFG-Tahoe&quot;, &quot;Name&quot;: &quot;TFG Tahoe&quot;, &quot;Authors&quot;: [{&quot;Name&quot;: &quot;RuRu&quot;}]}}<br>EOF</pre><p>這段只適用 PNG 圖片。做好之後在桌面按右鍵選 Desktop and Wallpaper，清單裡會多一個 TFG Tahoe，選它之後桌布就會跟著日夜模式一起換。</p><figure><img alt="KDE 的 Desktop Folder Settings 桌布頁面，圖片清單中有一張名為 TFG Tahoe 的桌布，縮圖以對角線同時顯示淺色與深色版本" src="https://cdn-images-1.medium.com/max/1024/1*0rWrWbReB8DzG0yZsIyrGA.png" /><figcaption><em>桌布清單裡多了 TFG Tahoe，縮圖同時顯示日夜兩個版本</em></figcaption></figure><p>縮圖會用一條對角線同時顯示淺色和深色兩個版本，代表 Plasma 認得這是一個有日夜版本的桌布套件。上面的 Switch dynamic wallpapers 維持預設的 Based on whether the Plasma style is light or dark 就好，桌布會跟著 Plasma 樣式的明暗切換。</p><h3>沒有做的兩件事</h3><p>登入畫面本來也想換，查了之後發現 Fedora 44 的 KDE 版改用新的 Plasma Login Manager 取代 SDDM，而它目前不支援自訂主題，固定使用 Breeze 樣式。網路上那些換登入畫面主題的教學，在 Fedora 44 上暫時不適用，除非把登入管理程式換回 SDDM。虛擬機裡比較常看到的其實是鎖定畫面，那是 Plasma 自己負責的，在 Screen Locking 的 Configure Appearance 可以換桌布。</p><p>另外左上角那顆蘋果是主題附的圖示。自己的虛擬機裡放著沒問題，只是這篇文章要公開，所以在這裡註明一下，這些都是第三方主題的致敬設計，跟 Apple 沒有關係。</p><p>全部調好之後，Global Theme 頁面的 Save Current Theme 可以把現在這一套存成自己的主題，之後想回到這個狀態，一鍵就好。</p><h3>寫在後面</h3><p>整個改造做完，回頭數一數，真正自己動手的其實只有幾件事：裝主題、選 Kvantum、搬按鈕、加 Global Menu，其他全是 KDE 內建就有的功能。一開始我還有點懷疑這樣算不算一篇文章，後來覺得這正是想說的：不需要是 Linux 高手，一個從 Mac 過來的人，在一個早上就能把 Fedora 調成自己習慣的樣子。</p><p>那位坐在屋頂邊緣的女孩，現在坐在浪花上，白天靠著彩帶，晚上抱著膝蓋看夜空。房子還是同一間，裝潢換了，住起來舒服多了。</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ehAafzXk-D7YzSGkzBP9nA.png" /><figcaption><em>改造完成的 Fedora 44 KDE，深色模式下開著 Dolphin 的最終桌面</em></figcaption></figure><p>Fedora 換上了 Tahoe 的衣服，骨子裡還是那個開源的 Fedora，這樣剛剛好。</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/82/0*i8Vs0QsAGcdzm2hk" /></figure><p><em>Originally published at </em><a href="https://swift.moe/zh-hant/fedora-44-kde-macos-tahoe-part-2-theme/"><em>https://swift.moe</em></a><em> on September 29, 2026.</em></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=b1fd627a818e" width="1" height="1" alt=""><hr><p><a href="https://medium.com/peter-pan-swift-ios-class/fedora-44-kde-macos-tahoe-part-2-theme-b1fd627a818e">Fedora 44 KDE 變身 macOS Tahoe（下）</a> was originally published in <a href="https://medium.com/peter-pan-swift-ios-class">彼得潘的 Swift iOS / Flutter App 開發教室</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Fedora 44 KDE 變身 macOS Tahoe（上）]]></title>
            <link>https://medium.com/peter-pan-swift-ios-class/fedora-44-kde-macos-tahoe-part-1-parallels-install-fc5af7918fab?source=rss----863d0bb9772b---4</link>
            <guid isPermaLink="false">https://medium.com/p/fc5af7918fab</guid>
            <category><![CDATA[apple-silicon]]></category>
            <category><![CDATA[parallels]]></category>
            <category><![CDATA[fedora]]></category>
            <category><![CDATA[linux]]></category>
            <category><![CDATA[kde]]></category>
            <dc:creator><![CDATA[RuRu (Sandy)]]></dc:creator>
            <pubDate>Sat, 03 Oct 2026 11:22:20 GMT</pubDate>
            <atom:updated>2026-10-03T11:22:18.953Z</atom:updated>
            <content:encoded><![CDATA[<h3>Fedora 44 KDE 變身 macOS Tahoe（上）：在 Apple Silicon Mac 用 Parallels 安裝，順便收集一路上的小陷阱</h3><p>這篇是從我的部落格同步過來的，中間的廣告是 Medium 平台放的。想看沒有廣告的乾淨版，歡迎來我的部落格閱讀：</p><p><a href="https://swift.moe/zh-hant/fedora-44-kde-macos-tahoe-part-1-parallels-install/">Fedora 44 KDE 變身 macOS Tahoe（上）：在 Apple Silicon Mac 用 Parallels 安裝，順便收集一路上的小陷阱</a></p><p>原本只是想寫一篇「把 Fedora 打扮成 macOS」的文章，結果光是把 Fedora 裝好、調到能安心截圖的狀態，就花了一整個早上。過程裡有幾個地方值得停下來看一下：dnf 下載時整段的 404 紅字、Parallels Tools 安裝時一整排的 semodule Failed、每五分鐘自動鎖定的螢幕。這些訊息看起來像出了問題，仔細把文句看完之後，有些只是可以放著不管的過程紀錄，有些則是需要另外找到設定去改的預設值。</p><p>這個系列分成上下兩篇。上篇把這些地方一個一個記下來，給同樣用 M 系列 Mac、想在 Parallels 裡跑 Fedora KDE 的人參考；改造成 macOS 外觀的部分，放在下篇。</p><h3>Parallels 那顆下載按鈕，裝的是哪個 Fedora</h3><p>打開 Parallels 的 Create New 畫面，下方有一排 Free Systems，其中一顆就是 Download Fedora Linux，看起來很方便。不過它沒有寫清楚給的是哪個版本，而 Fedora 其實有好幾種桌面環境不同的版本。</p><figure><img alt="Parallels Desktop 的 Create New 視窗，列出可下載的免費系統，其中選到 Fedora Linux" src="https://cdn-images-1.medium.com/max/872/1*ok-4svnmKYW2eUES54HDPA.png" /><figcaption><em>Create New 畫面下方，就是免費系統的下載按鈕</em></figcaption></figure><p>先講一下背景。Fedora 是作業系統，而我們平常看到的桌面長相，是由「桌面環境」決定的。用房子來比喻的話，Fedora 是建商蓋好的整棟房子，桌面環境是整套室內裝潢。Fedora 官方提供好幾種預先裝潢好的版本，最主流的 Workstation 用的是 GNOME，另外有一個 KDE Plasma Desktop 版本，用的是 KDE Plasma。KDE Plasma 不是 Fedora 自己做的工具，而是 KDE 社群開發的開源桌面環境，只是 Fedora 願意幫你先擺好。</p><p>我實際下載了 Parallels 那一台來看，開機後在終端機輸入 echo $XDG_CURRENT_DESKTOP ，印出來的是 GNOME。也就是說，這顆按鈕給的是 Workstation 版，而且連帳號都幫你建好了（使用者名稱是 parallels），是一台打包好的現成虛擬機，不是安裝光碟。</p><figure><img alt="Fedora GNOME 桌面的終端機視窗，指令 echo $XDG_CURRENT_DESKTOP 的結果是 GNOME" src="https://cdn-images-1.medium.com/max/1024/1*HojrhcT_IpKbMGaqxr9bJg.png" /><figcaption><em>Parallels 下載的 Fedora，一查是 GNOME</em></figcaption></figure><p>想用 KDE Plasma 的話，比較乾淨的做法是去 Fedora 官網下載 KDE Plasma Desktop 版的 ISO。M 系列的 Mac 要選 aarch64（也就是 ARM64）的版本，然後在 Parallels 選中間那個「Install Windows, Linux, or macOS from an image file」，把 ISO 丟進去。官網的下載頁分成 x86_64 和 aarch64 兩區，aarch64 那一區有 Live ISO 和 Raw 兩種格式，星號標在 Raw 上，不過 Parallels 要用的是 Live ISO。雖然在 GNOME 版上也能額外安裝 KDE，但兩套桌面環境住在同一間房子裡，偶爾會搶遙控器。</p><figure><img alt="Fedora KDE Plasma Desktop 44 的下載頁面，分為 x86_64 與 ARM aarch64 兩區，aarch64 區有 Live ISO 與 Raw 兩個選項" src="https://cdn-images-1.medium.com/max/1024/1*rxYuIeyPBYz7m5ZCGFTT3g.png" /><figcaption><em>Fedora 官網的 KDE Plasma Desktop 下載頁，aarch64 要選 Live ISO</em></figcaption></figure><p>順帶一提，這兩個桌面環境的預設長相剛好很有戲：GNOME 頂端有一條列但沒有 Dock，像是只學了一半的 macOS；KDE 的預設是底部一條工作列，左邊開始選單、右邊系統匣，比較像 Windows 的遠房表親。這也是為什麼下篇要拿 KDE 來改造，它的自訂彈性很大。</p><h3>第一次更新，滿螢幕紅字的洗禮</h3><p>裝好進到桌面，第一件事是更新系統。下載的 ISO 是 Fedora 44 發布那天（2026 年 4 月 28 日）封存的版本，從那天到現在累積了不少修正，我更新時是 1097 個套件、2.2 GB。</p><p>打開 Konsole，也就是 KDE 的終端機，角色和 Mac 的 Terminal 一樣，輸入 sudo dnf upgrade 。第一次使用 sudo 會看到一段三條守則的訓話，之後就不會再出現。輸入密碼時畫面不會有任何反應，連星號都沒有，這是正常的，打完直接按 Enter 就好。</p><p>接著會出現一大段紅字，寫著 Status code: 404，看起來像下載失敗，其實不是。</p><figure><img alt="Konsole 中 dnf upgrade 的下載畫面，出現紅色的 Status code 404 錯誤訊息，但總進度仍在繼續" src="https://cdn-images-1.medium.com/max/1024/1*Y2Qwzd1-bnZ2nKrTQ4eVMw.png" /><figcaption><em>紅字 404 只是這間鏡像站還沒補貨，dnf 會自己換一間</em></figcaption></figure><p>Fedora 的套件放在全世界很多台鏡像站上，dnf 會就近挑一台下載。404 的意思是這台鏡像站剛好還沒同步到那幾個套件，像是巷口的便利商店還沒補貨。dnf 遇到這種情況會自動換一間，紅字只是過程的紀錄。仔細看，那幾個套件其實都已經在別的鏡像站下載到 100%。如果真的跑到最後失敗，重新執行 sudo dnf upgrade --refresh 通常就能解決。</p><p>下載完會問要不要匯入一把 OpenPGP key，這是 Fedora 的官方印章。每個套件發布前都會蓋章，電腦要拿對應的公鑰來比對，確認套件真的是 Fedora 做的、途中沒有被動過。它是從電腦裡本來就有的檔案讀進來的（路徑開頭是 file:///etc/pki），跟著 ISO 一起裝進來，可以放心按 y。這個詢問也只有第一次會出現。</p><figure><img alt="Konsole 中 dnf 詢問是否匯入 Fedora 44 的 OpenPGP key，來源為本機的 /etc/pki 路徑" src="https://cdn-images-1.medium.com/max/1024/1*B9iqRmRF5X4JiugehxhTFg.png" /><figcaption><em>第一次更新會問要不要匯入 Fedora 的簽章金鑰</em></figcaption></figure><p>安裝過程中偶爾會出現橘色的 Scriptlet output，那是某些套件裝完後執行的收尾腳本在報告工作內容，不是錯誤。另外一開始下載是 1097 個套件，安裝時卻變成 2149 個步驟，因為更新其實是「裝上新版」加「清掉舊版」兩個動作，每個套件都要算兩次。</p><p>跑到最後會看到 Complete!，然後就沒有然後了。Linux 不會催你重開機，什麼時候重開由管理員自己決定。不過這次更新到了核心，畫面上會看到 Generating grub configuration file 這類字樣，代表引擎本身換了新的，需要重開機才會生效。重開前後各輸入一次 uname -r ，可以看到核心版本的變化。重開機登入後，也會看到 Plasma 已經更新到 6.7 的歡迎畫面。</p><figure><img alt="Konsole 中 dnf upgrade 結束畫面，顯示 grub 設定產生完成與 Complete!" src="https://cdn-images-1.medium.com/max/1024/1*pW1JMCIlE9IPsCO-TlomNA.png" /><figcaption><em>更新完成，核心也換了，這時候需要手動重開機</em></figcaption></figure><p>不習慣終端機的話，Dock 上那顆藍色購物袋 Discover 是 KDE 的軟體中心，裡面有 Updates 分頁可以用滑鼠更新。只是兩種方式不要同時跑，它們會搶同一把鎖。</p><h3>Parallels Tools 在 Linux 上不是點兩下就好</h3><p>在 Windows 或 macOS 的虛擬機裡，Parallels Tools 是點兩下就裝好的東西。在 Linux 上不是，因為它需要在你的機器上現場編譯驅動程式。Windows 和 macOS 的核心規格固定，驅動可以事先做好，像成衣一樣；Linux 的核心版本太多，發行版又各有各的組合，Parallels 只好把布料和版型寄給你，在你家現場裁縫。</p><p>所以要先準備工具：</p><pre> sudo dnf install kernel-devel-$(uname -r) gcc make</pre><p>然後點系統匣跳出的光碟通知，選 Open with File Manager，會用 Dolphin 打開光碟。Dolphin 是 KDE 的檔案管理員，也就是 Mac 的 Finder、Windows 的檔案總管在這裡的對應角色，接下來兩篇會一直看到它。在 Dolphin 裡按 F4，視窗下方會打開一個已經位在光碟資料夾裡的終端機，直接輸入：</p><pre> sudo ./install</pre><p>跟著文字版的安裝精靈按 Next 就好。</p><p>安裝到 60% 左右會冒出一整段 Failed to resolve、semodule: Failed!，最後一行卻寫 Parallels Guest Tools were installed successfully!，兩者看起來矛盾，值得弄清楚到底是哪個部分失敗了。</p><figure><img alt="Konsole 中 Parallels Tools 安裝輸出，中段有多行 semodule Failed 的訊息，最後一行顯示安裝成功" src="https://cdn-images-1.medium.com/max/1024/1*W7WalUitCEWeIpQ_vSpWzQ.png" /><figcaption><em>安裝 Parallels Tools 時出現的 SELinux 錯誤，最後其實有裝成功</em></figcaption></figure><p>這些錯誤跟 SELinux 有關。SELinux 是 Fedora 內建的一位嚴格保全，手上有一本規則書，寫著哪個程式可以碰哪些東西。Parallels Tools 自備了一份規則附錄想交給保全，但附錄是照舊版規則書寫的，裡面有些名詞 Fedora 44 已經不認得，於是被退回。</p><p>被退回之後，這兩個服務會怎麼樣？我查了兩件事。第一是它們被歸在哪個身分：</p><pre> ps -eZ | grep prl</pre><p>結果五個 Parallels 服務都是 unconfined_service_t，也就是「不受限制的服務」。原來那份附錄的用途是主動申請被管束，附錄沒被收下，它們反而變成一般住戶自由進出。第二是查有沒有被擋過的紀錄：</p><pre> sudo ausearch -m avc -ts boot | grep prl</pre><p>印出 no matches，代表這次開機以來 SELinux 一次都沒擋過它。</p><figure><img alt="Konsole 中 ps -eZ 顯示 Parallels 服務為 unconfined_service_t，ausearch 查詢結果為 no matches" src="https://cdn-images-1.medium.com/max/1024/1*Qcde3NIk1GA-l6JF1mVbMA.png" /><figcaption><em>兩行指令確認 Parallels 沒被 SELinux 擋住</em></figcaption></figure><p>網路上常看到「把 SELinux 關掉就好」的建議，這等於為了讓一個快遞員進門，把整棟大樓的保全都開除。既然查證後功能沒受影響，就讓它繼續站崗。裝完重開機，Parallels 標題列的驚嘆號就會消失，視窗大小也能自動跟著調整了。</p><h3>虛擬機每五分鐘把我鎖在門外</h3><p>接下來的問題是，放著不動幾分鐘，Fedora 就會跳出鎖定畫面要密碼。在虛擬機裡這實在沒必要。</p><p>我先去 Power Management 把 Dim automatically、Turn off screen 改成 Never，When inactive 改成 Do nothing。這裡有個小細節：Parallels 會把 Mac 的電池狀態傳進虛擬機，所以 Fedora 以為自己是一台筆電，設定頁有 On AC Power、On Battery、On Low Battery 三個分頁，三個都要改，不然筆電沒插電的時候用的是另一組設定。</p><figure><img alt="KDE 系統設定的 Power Management 頁面，顯示 On AC Power、On Battery、On Low Battery 三個分頁" src="https://cdn-images-1.medium.com/max/1024/1*kehP_jeEF6NW--N2X3tBQA.png" /><figcaption><em>電源管理有三個分頁，因為虛擬機也看得到 Mac 的電池</em></figcaption></figure><p>改完還是會被鎖，因為鎖定是另一個設定頁在管。在系統設定的搜尋框輸入 lock 找到 Screen Locking，Lock screen automatically 預設是 5 minutes，改成 Never，再把 Lock after waking from sleep 取消勾選。後面這個很容易漏掉，Parallels 暫停再恢復虛擬機時，Fedora 會覺得自己剛睡了一覺醒來。</p><figure><img alt="KDE 系統設定的 Screen Locking 頁面，Lock screen automatically 設為 5 minutes，Lock after waking from sleep 已勾選" src="https://cdn-images-1.medium.com/max/1024/1*QQmjpehxKiKLla4bvqBhjg.png" /><figcaption><em>兇手在這裡，預設每 5 分鐘自動鎖定</em></figcaption></figure><p>兩個都改好按 Apply 之後，虛擬機放多久都不會再跳出鎖定畫面，右下角的 Apply 變成灰色就代表設定已經存好了。</p><h3>在桌面放一個通往 Mac Downloads 的捷徑</h3><p>Parallels Tools 裝好之後，Fedora 桌面會多一個 Parallels Shared Folders 資料夾，Mac 的家目錄就在裡面。不過每次都要點進 Home、再點進 Downloads，傳個截圖或主題檔案有點麻煩，我想要桌面上直接有一個入口。</p><p>想在桌面放一個通往 Mac Downloads 的捷徑，可以用符號連結，概念跟 Finder 的替身一樣。共用資料夾的本尊路徑在 /media/psf 底下：</p><pre> ln -s /media/psf/Home/Downloads ~/Desktop/&quot;Mac Downloads&quot;</pre><p>執行完，桌面上會出現一個叫 Mac Downloads 的資料夾，圖示右下角有一個小箭頭，代表它是替身而不是本尊。點兩下進去就是 Mac 的 Downloads，之後在 Mac 存好的檔案，Fedora 這邊馬上看得到，截圖和主題檔案都走這條路傳。</p><h3>Mac 鍵盤在 Linux 裡的小落差</h3><p>最後是幾個從 Mac 過來一定會撞到的地方。Cmd 鍵傳進 Linux 會變成 Meta 鍵，Linux 的複製貼上用的是 Ctrl，而在終端機裡 Ctrl + C 是中斷程式的緊急煞車，所以 Konsole 的貼上是 Ctrl + Shift + V。Alt + Space 是 KDE 的搜尋列 KRunner，但這組快捷鍵在 Mac 上常被其他程式先攔走，點左上角的開始選單用裡面的搜尋框，效果一樣。</p><p>還有一個 Mac 沒有的小功能：用滑鼠選取一段文字就等於複製了，按滑鼠中鍵就能貼上。</p><h3>這次的環境</h3><p>主機是 MacBook Pro 14 吋（M1 Max，64 GB），跑 macOS Golden Gate 27.2，虛擬化軟體是 Parallels Desktop 27 for Mac 標準版（27.0.2），分給虛擬機 4 核心、8 GB。客體系統是 Fedora 44 KDE Plasma Desktop Edition 的 aarch64 版，更新後的 KDE Plasma 是 6.7，核心是 7.2.7。半年後照著做如果長得不一樣，多半是版本差異，不是步驟錯了。</p><h3>寫在後面</h3><p>回頭看，這一個早上遇到的紅字和 Failed，沒有一個是真的壞掉。404 是鏡像站還沒補貨，SELinux 的 Failed 是附錄被退件但功能照常，橘色的 Scriptlet 是收尾報告。它們的共同點是訊息本身寫得很清楚，把文句仔細讀完就知道發生了什麼，而不是看到紅字就先關掉什麼或重裝什麼。Linux 只是比較誠實，把過程裡發生的每一件事都印給你看。</p><p>過程中最有幫助的一個習慣，是每到一個穩定的狀態就在 Parallels 拍一張快照。更新完拍一張，Parallels Tools 裝完拍一張，這樣不管後面怎麼折騰，都有地方可以回去。</p><p>動工之前，我把這時候的桌面拍了一張留念。開始選單開著，底部的工作列還在，圖示還是 KDE 的 Breeze 風格，桌布上坐在屋頂邊緣的女孩是我把自己畫的角色放進 Fedora 原廠夜景做出來的。這是它穿 KDE 原廠衣服的最後一張照片。</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*rLntjmluxzsXY6b6H7q2vA.png" /><figcaption><em>改造前的 Fedora KDE 桌面，留作紀念</em></figcaption></figure><p>下篇就要開始把這台 Fedora 打扮成 macOS 了。小陷阱都踩過一輪，接下來終於可以挑窗簾。</p><p>下篇： <a href="https://swift.moe/zh-hant/fedora-44-kde-macos-tahoe-part-2-theme/">把 KDE Plasma 打扮成 macOS 介面</a></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/82/0*3Dl5h-Jdn2WVQrTc" /></figure><p><em>Originally published at </em><a href="https://swift.moe/zh-hant/fedora-44-kde-macos-tahoe-part-1-parallels-install/"><em>https://swift.moe</em></a><em> on September 29, 2026.</em></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=fc5af7918fab" width="1" height="1" alt=""><hr><p><a href="https://medium.com/peter-pan-swift-ios-class/fedora-44-kde-macos-tahoe-part-1-parallels-install-fc5af7918fab">Fedora 44 KDE 變身 macOS Tahoe（上）</a> was originally published in <a href="https://medium.com/peter-pan-swift-ios-class">彼得潘的 Swift iOS / Flutter App 開發教室</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[[彼得潘UIKit作業7]自訂 controller 類別和利用 viewDidLoad 客製 App 畫面]]></title>
            <link>https://medium.com/peter-pan-swift-ios-class/%E5%BD%BC%E5%BE%97%E6%BD%98uikit%E4%BD%9C%E6%A5%AD7-%E8%87%AA%E8%A8%82-controller-%E9%A1%9E%E5%88%A5%E5%92%8C%E5%88%A9%E7%94%A8-viewdidload-%E5%AE%A2%E8%A3%BD-app-%E7%95%AB%E9%9D%A2-18603fc93274?source=rss----863d0bb9772b---4</link>
            <guid isPermaLink="false">https://medium.com/p/18603fc93274</guid>
            <dc:creator><![CDATA[Ting]]></dc:creator>
            <pubDate>Mon, 28 Sep 2026 14:24:42 GMT</pubDate>
            <atom:updated>2026-09-28T14:24:39.009Z</atom:updated>
            <content:encoded><![CDATA[<p>作業需求如下：</p><p><a href="https://medium.com/%E5%BD%BC%E5%BE%97%E6%BD%98%E7%9A%84%E8%A9%A6%E7%85%89-%E5%8B%87%E8%80%85%E7%9A%84-100-%E9%81%93-swift-ios-app-%E8%AC%8E%E9%A1%8C/%E5%88%A9%E7%94%A8-controller-%E7%9A%84-viewdidload-%E8%A3%BD%E4%BD%9C%E5%90%B8%E7%9D%9B-app-%E7%95%AB%E9%9D%A2-8e4d02d86177">#91 自訂 controller 類別和利用 viewDidLoad 客製 App 畫面</a></p><p>當 controller 的 view 被載入到記憶體，但畫面未被顯示之前，系統會自動呼叫viewDidLoad()，適合放那些當畫面第一次載入時，只需要設定一次的初始化工作。</p><p>所以我做了一個 Snoopy 看夜空的作業，點了按鈕後，月亮會有 Snoopy跳舞：</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Afg0FPovRw45h6uipowRAA.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ThPpL3CPAkos2AAAQBqZ_Q.png" /></figure><p><strong>利用 CAEmitterLayer 製作流星雨</strong></p><p><a href="https://medium.com/%E5%BD%BC%E5%BE%97%E6%BD%98%E7%9A%84-swift-ios-app-%E9%96%8B%E7%99%BC%E5%95%8F%E9%A1%8C%E8%A7%A3%E7%AD%94%E9%9B%86/%E5%88%A9%E7%94%A8-caemitterlayer-%E8%A3%BD%E4%BD%9C-xmas-%E7%9A%84%E4%B8%8B%E9%9B%AA%E5%8B%95%E7%95%AB-ee5f7cae621e">利用 CAEmitterLayer 製作 Xmas 的下雪動畫</a></p><p>CAEmitterCell 是每一種<strong>粒子規格</strong>，負責決定粒子的大小、一次生幾個、速度多快、活多久、往哪裡飛。</p><pre>// 製作星星的粒子<br>let starEmitterCell = CAEmitterCell()<br>starEmitterCell.contents = UIImage(named: &quot;star&quot;)?.cgImage<br>starEmitterCell.birthRate = 1<br>starEmitterCell.lifetime = 5<br>starEmitterCell.scale = 0.1<br>starEmitterCell.scaleRange = 0.1    // 讓星星有大有小<br>starEmitterCell.velocity = 100<br>starEmitterCell.yAcceleration = 100 // 讓星星向下移動<br>starEmitterCell.xAcceleration = -10 // 讓星星向左移動</pre><p>CAEmitterLayer 是<strong>發射器本體</strong>，負責決定粒子從哪裡出現、發射區域長怎麼樣。</p><pre>let starEmitterLayer = CAEmitterLayer()<br>starEmitterLayer.emitterCells = [starEmitterCell]<br>        <br>// 設定粒子從哪發射<br>starEmitterLayer.emitterPosition = CGPoint(x: view.bounds.width / 2, y: -50)<br>// 設定發射區域多大<br>starEmitterLayer.emitterSize = CGSize(width: view.bounds.width, height: 0)<br>// 設定發射區域的形狀<br>starEmitterLayer.emitterShape = .line<br>view.layer.addSublayer(starEmitterLayer)</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*w9XYfxSglhQ9A6LnOJz3HQ.gif" /></figure><p><strong>用 UIImageView 實現多張圖片連續播放的動畫效果</strong></p><p><a href="https://medium.com/%E5%BD%BC%E5%BE%97%E6%BD%98%E7%9A%84-swift-ios-app-%E9%96%8B%E7%99%BC%E5%95%8F%E9%A1%8C%E8%A7%A3%E7%AD%94%E9%9B%86/%E5%88%A9%E7%94%A8-uiimageview-%E6%92%AD%E6%94%BE%E5%8B%95%E7%95%AB-f48959b6882a">利用 UIImageView 實現多張圖片連續播放的動畫</a></p><p>轉圈圈飛的黃色小鳥 Woodstock：</p><pre>let woodstockImageView = UIImageView(frame: CGRect(x: 100, y: 400, width: 199, height: 174))<br>view.addSubview(woodstockImageView)<br>let animatedImage = UIImage.animatedImageNamed(&quot;woodstock-&quot;, duration: 1)<br>woodstockImageView.image = animatedImage</pre><p>點擊按鈕後，在月亮上跳舞的 Snoopy ：</p><pre>let snoopyDancingImageView = UIImageView(frame: CGRect(x: 30, y: -42, width: 350, height: 350))<br>view.addSubview(snoopyDancingImageView)<br>let snoopyAnimatedImage = UIImage.animatedImageNamed(&quot;love-woodstock-&quot;, duration: 12)<br>snoopyDancingImageView.image = snoopyAnimatedImage</pre><p><strong>利用 UIKit 的 mask 跟 SF Symbol 設計特別形狀的圖片</strong></p><ul><li><a href="https://medium.com/%E5%BD%BC%E5%BE%97%E6%BD%98%E7%9A%84-swift-ios-app-%E9%96%8B%E7%99%BC%E5%95%8F%E9%A1%8C%E8%A7%A3%E7%AD%94%E9%9B%86/%E5%88%A9%E7%94%A8-uiview-%E7%9A%84-mask-%E8%A8%AD%E8%A8%88%E7%89%B9%E5%88%A5%E5%BD%A2%E7%8B%80%E7%9A%84%E5%9C%96%E7%89%87-4e22cd7c3fbe">利用 UIView 的 mask 設計特別形狀的圖片</a></li><li><a href="https://medium.com/%E5%BD%BC%E5%BE%97%E6%BD%98%E7%9A%84-swift-ios-app-%E9%96%8B%E7%99%BC%E5%95%8F%E9%A1%8C%E8%A7%A3%E7%AD%94%E9%9B%86/%E5%88%A9%E7%94%A8-sf-symbol-%E5%92%8C-mask-%E8%A3%81%E5%88%87%E5%A4%9A%E9%82%8A%E5%BD%A2-e2814bfa83b1">利用 SF Symbol 和 mask 裁切多邊形</a></li></ul><p>因為圖片中的月亮剛好是圓形，我就用 SF Symbol 的 circle.fill 來做遮罩。</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*TKjqnu1GXQS8aA0Rd-lsaw.png" /></figure><pre>let maskImageView = UIImageView(frame: CGRect(x: 0, y: 0, width: 350, height: 350))<br>maskImageView.image = UIImage(systemName: &quot;circle.fill&quot;)<br>maskImageView.contentMode = .scaleAspectFit<br>snoopyDancingImageView.mask = maskImageView</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*RSVXI9017DB0mLAk4eQN8g.gif" /></figure><p><strong>成品</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/960/1*Y4LwfrxoWkSVGYvn13HWzA.gif" /></figure><p><strong>專案在 GitHub 網址</strong></p><p><a href="https://github.com/star820819/hw7_viewDidLoad">GitHub - star820819/hw7_viewDidLoad</a></p><p><strong>心得</strong></p><p>在剛開始接觸 Swift 時，都會把程式直接寫在viewDidLoad()裡，直到這次作業，才真正了解它是在做什麼，知道了在這個 ViewController 的 view 第一次載入到記憶體時，系統會主動呼叫 viewDidLoad()，去做畫面的初始化工作，而且並不是每次畫面都會執行這個 function ，如果每次回到這個畫面都要更新的東西，就不適合放在這裡，要考慮放在 viewWillAppear()裡。</p><p>這次在寫程式的過程中還算簡單，Peter 寫的都很清楚，直接照抄再微調就好 XD。可是在理解 CAEmitterLayer 時，我看不太懂，於是我就問 ChatGPT，後來就看懂了；而這次作業的 README 也是用 Codex 寫的，發現 AI 現在真的好強，一下下就產生我要花很久時間才能產出的結果，或許還沒 AI 寫得好，AI 真是讓人又愛又恨！</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=18603fc93274" width="1" height="1" alt=""><hr><p><a href="https://medium.com/peter-pan-swift-ios-class/%E5%BD%BC%E5%BE%97%E6%BD%98uikit%E4%BD%9C%E6%A5%AD7-%E8%87%AA%E8%A8%82-controller-%E9%A1%9E%E5%88%A5%E5%92%8C%E5%88%A9%E7%94%A8-viewdidload-%E5%AE%A2%E8%A3%BD-app-%E7%95%AB%E9%9D%A2-18603fc93274">[彼得潘UIKit作業7]自訂 controller 類別和利用 viewDidLoad 客製 App 畫面</a> was originally published in <a href="https://medium.com/peter-pan-swift-ios-class">彼得潘的 Swift iOS / Flutter App 開發教室</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[iCloud 密碼不自動填驗證碼？問題出在 SPA 的登入表單]]></title>
            <link>https://medium.com/peter-pan-swift-ios-class/icloud-passwords-otp-spa-login-form-a3288545b3c1?source=rss----863d0bb9772b---4</link>
            <guid isPermaLink="false">https://medium.com/p/a3288545b3c1</guid>
            <category><![CDATA[javascript]]></category>
            <category><![CDATA[react]]></category>
            <category><![CDATA[中文-繁體]]></category>
            <category><![CDATA[web-development]]></category>
            <category><![CDATA[debugging]]></category>
            <dc:creator><![CDATA[RuRu (Sandy)]]></dc:creator>
            <pubDate>Wed, 23 Sep 2026 14:59:26 GMT</pubDate>
            <atom:updated>2026-09-23T14:59:25.698Z</atom:updated>
            <content:encoded><![CDATA[<p>這篇是從我的部落格同步過來的，中間的廣告是 Medium 平台放的。想看沒有廣告的乾淨版，歡迎來我的部落格閱讀：</p><p><a href="https://swift.moe/zh-hant/icloud-passwords-otp-spa-login-form/">iCloud 密碼不自動填驗證碼？問題出在 SPA 的登入表單</a></p><p>最近幫實驗室的研究儀表板加上兩步驟驗證。功能本身都做完了，驗證器 App、密碼金鑰、備用碼、信箱補救一樣不缺，結果卡在一個很小、卻每天登入都會撞到的地方。</p><p>在 Dropbox 或 Discord 登入時，只要點一下驗證碼欄位，Chrome 上的 iCloud 密碼擴充功能就會跳出「Verification code for …」，再點一下就填好了。換到我自己的網站，同樣的欄位、同樣的擴充功能、同樣一組存在「密碼」App 裡的驗證碼，點下去要嘛一片安靜，要嘛跳出一串帳號，就是沒有驗證碼。</p><p>這篇記錄我怎麼從「一定是我屬性寫錯」，一路追到「其實是頁面切換的方式」。結論只有一行程式碼，不過我覺得中間的方法，比結論本身值錢。</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/988/1*ADSXEPicU7KujNdQ5pHV0g.png" /><figcaption>別人家的：點驗證碼欄，iCloud 密碼直接把驗證碼端上來。</figcaption></figure><figure><img alt="SleepAtlas 兩步驟驗證畫面，驗證碼輸入框已聚焦，右側只有一個鑰匙圖示，沒有跳出任何自動填寫建議" src="https://cdn-images-1.medium.com/max/962/1*Wi4eQw0_h3czi2pgGEfcjA.png" /><figcaption>我家的：欄位點了就是一片安靜。</figcaption></figure><h3>先把「不是問題的東西」排掉</h3><p>排錯最容易浪費時間的地方，是在錯的層猜對的答案。所以動手之前，先確認三件事。</p><p>資料真的在嗎？打開「密碼」App，Mac 和 iPhone 上這個網站的條目都有「驗證碼」欄位，數字也乖乖在跳。這一步把「沒存到」和「iCloud 沒同步」都排除掉了。我一開始還懷疑過同步延遲，白白繞了一圈；先看資料再猜機制，真的可以省很多時間。</p><figure><img alt="macOS 密碼 App 顯示 atlas.swift.moe 的帳號條目，含 Passkey、Password、Code 三個欄位，Code 旁有倒數圈" src="https://cdn-images-1.medium.com/max/1024/1*JTF6HTckuwsXTexWet3Uog.png" /><figcaption>資料在，而且 Mac 和 iPhone 都在。問題不在這一層。</figcaption></figure><p>是誰在填？Safari 內建的自動填寫，和 Chrome 上的 iCloud Passwords 擴充功能，是兩套完全不同的程式。黑底、標題寫著「iCloud Passwords」、最下面有「Open Passwords App」的那個，是 Chrome 擴充功能。要是認錯對象，後面讀的程式碼就全部白讀了。</p><p>那是屬性寫錯嗎？這是我最先懷疑、也最先被推翻的假設。把 Dropbox 的驗證碼欄位挖出來看：</p><pre>&lt;input type=&quot;text&quot; autocomplete=&quot;one-time-code&quot; inputmode=&quot;numeric&quot; …&gt;</pre><p>和我的一字不差。Discord 的也一樣有 autocomplete=”one-time-code”，只是多了 maxlength=”6″。同樣的屬性，人家能用我不能用，「屬性」這一整類假設可以先收起來了。</p><h3>那就把擴充功能拆開來讀</h3><p>擴充功能裝在 Chrome 的設定檔資料夾裡，macOS 用 TCC 把那個目錄保護起來，終端機讀不到。沒關係，Chrome Web Store 可以直接抓 CRX：</p><pre>curl -sL -o ext.crx &quot;https://clients2.google.com/service/update2/crx?response=redirect&amp;prodversion=131.0&amp;acceptformat=crx2,crx3&amp;x=id%3Dpejdijmoenmkgeppbflobdenhhabjlaj%26uc&quot;</pre><p>CRX3 只是一段簽章標頭加上一個普通的 zip，找到 PK\x03\x04 的位置，把前面切掉就好：</p><pre>data = open(&quot;ext.crx&quot;, &quot;rb&quot;).read()<br>i = data.find(b&quot;PK\x03\x04&quot;)<br>open(&quot;ext.zip&quot;, &quot;wb&quot;).write(data[i:])</pre><p>解開之後，主要就是三個檔案。content_script.js 有 846 KB，跑在每一個網頁裡，負責找欄位、分類欄位、分類表單，然後決定要不要跳彈窗。background.js 跑在擴充功能的背景，負責和 Mac 上的原生程式講話，向「密碼」資料庫要帳號和驗證碼。completion_list.js 就是那個黑底彈窗本身，依表單型別決定要顯示帳號清單，還是驗證碼。</p><p>雖然是壓縮過的程式碼，關鍵字倒是都沒有改名。拿 one-time-code、OneTimeCode、AutoFillFormType 往回追，可以找到四個決策點。_looksLikeOneTimeCodeField 負責認欄位：有 one-time-code 這個 token 就直接算驗證碼欄，沒有的話再靠 maxlength、pattern、placeholder 去猜。elementWasFocused 接著 shouldOfferCompletionListForField，只有使用者自己點進欄位才會要求彈窗，程式呼叫 focus() 不算數。updateCompletionList 看的是 AutoFillFormType：判成登入表單就去要帳號清單，欄位是驗證碼欄就去要驗證碼。最後由 getOneTimeCodes 把目前的網址送給原生程式，原生程式再回傳符合的驗證碼。</p><p>讀到 updateCompletionList 那一段，我心裡大概有底了。彈窗裡列的是帳號而不是驗證碼，代表它把我的表單判成了「登入表單」，還把驗證碼欄當成了「帳號欄」。但為什麼會這樣判，光看程式碼看不出來，得拿真的頁面去餵它。</p><h3>用它自己的腦袋測我的頁面</h3><p>content_script.js 就是普通的 JavaScript，把它灌進我的頁面，就能直接問它「你覺得這個欄位是什麼」。我用 Playwright 開本機 dev 的登入頁，先塞一個假的 chrome.runtime（sendMessage、connect、onMessage、onConnect 都給空實作），再用 addScriptTag 把整個 content script 灌進去，模擬 background 送來的 setUp 訊息，讓它掛好事件、做完第一次掃描，然後真的走一次登入，進到第二步驟，呼叫它的內部函式：</p><pre>const el = document.querySelector(&quot;#code&quot;);<br>const [control, form] = FormMetadataJS.textFieldOrSelectElementMetadata(el, 0);<br>console.log({<br>  otp: control.ControlLooksLikeOneTimeCodeField,<br>  formType: form.AutoFillFormType,                       // 3 = login form<br>  codeIsTreatedAsUsername: control.ControlUniqueID === form.UsernameElementUniqueID,<br>});</pre><p>結果：</p><pre>otp: true<br>formType: 3<br>codeIsTreatedAsUsername: true</pre><p>欄位確實被認成驗證碼欄，但整個表單被判成登入表單，而這個「登入表單」的帳號欄，就是驗證碼欄本人。</p><p>一個欄位同時是驗證碼欄又是帳號欄，後面的流程就分岔了。updateCompletionList 看到登入表單，去要了帳號清單，彈窗裡於是列出一串帳號；驗證碼那條路雖然也走了，卻被帳號欄的流程蓋過去。我看到的「跳出一串帳號、沒有驗證碼」，完整的原因就是這樣。</p><p>順帶一提，這個實驗不一定要寫腳本。用裝著擴充功能的 Chrome 打開 DevTools，把 Console 左上角那個寫著 top 的下拉選單切到 iCloud Passwords，就進到擴充功能的執行環境了，直接貼上面那段，就能看到它對目前頁面的判斷。我當時是為了一次跑很多種寫法，才用 Playwright。</p><p>回頭翻先前的截圖，才發現線索早就躺在那裡。Chrome 自己的密碼管理員，也曾經在我的驗證碼欄跳出帳號密碼的建議。兩套完全不同的程式，對同一個欄位犯同一種錯，那就不太像是誰的 bug，比較像是我的頁面真的長得像登入表單。</p><figure><img alt="SleepAtlas 兩步驟驗證畫面，驗證碼輸入框下方顯示 Chrome 內建密碼管理員的建議泡泡，列出同網域的帳號密碼與 Manage Passwords 選項" src="https://cdn-images-1.medium.com/max/914/1*QxIp57E0qaHoPmlAWCWP5A.png" /><figcaption>Chrome 也把它當帳號欄。這張圖我第一次看到時沒讀懂。</figcaption></figure><h3>真相：不是欄位，是頁面怎麼換的</h3><p>擴充功能在頁面載入時掃一次，把「帳號欄＋密碼欄」記成一個邏輯表單，之後不會因為 DOM 變動就重掃。這點我在程式碼裡確認過：沒有 MutationObserver 盯著整頁，只有 background 送來的 setUp、getPageType、runFormMetadataHeuristics 三個訊息會觸發重掃，而這三個都和導覽有關。</p><p>我的登入頁是 SPA。進到第二步驟時，React 把整個登入表單拆掉，在同一個位置放上只有驗證碼欄的新表單。擴充功能拿舊快取去對新表單，第一個文字欄位就接到了「帳號欄」的位置，也就是驗證碼欄。</p><p>Dropbox 和 Discord 也都是 SPA，為什麼沒事？Dropbox 的六個小格子根本不在 form 裡，不會被拿去跟任何登入表單比對。Discord 的多重認證則是一個 modal，蓋在還活著的登入表單上面，舊快取依然正確，modal 裡的欄位另外分類。</p><p>所以不是 SPA 的錯。是「在同一份文件裡把登入表單拆掉、換成驗證碼表單」加上「擴充功能不重掃」，兩件事湊在一起才會壞。</p><p>我還特別測了一件事：把 Discord 欄位的屬性（name=””、maxlength=”6″、minlength=”6″，還有它的 placeholder）原封不動套到我的舊版表單上，再跑一次分類器。結果一樣是 codeIsTreatedAsUsername: true。屬性從頭到尾就不是重點。</p><h3>修法：一個看不見的帳號欄位</h3><p>既然擴充功能想在表單裡找帳號欄，那就給它一個。在第二步驟的表單裡放一個視覺上隱藏的帳號欄位，值是使用者剛剛輸入的帳號：</p><pre>function HiddenUsername({ value }: { value: string }) {<br>  return (<br>    &lt;input<br>      type=&quot;text&quot;<br>      name=&quot;username&quot;<br>      autoComplete=&quot;username&quot;<br>      defaultValue={value.trim()}<br>      tabIndex={-1}<br>      aria-hidden<br>      className=&quot;sr-only&quot;<br>    /&gt;<br>  );<br>}<br><br>&lt;form onSubmit={onVerify} noValidate&gt;<br>  &lt;HiddenUsername value={username} /&gt;<br>  &lt;input id=&quot;code&quot; name=&quot;code&quot; autoComplete=&quot;one-time-code&quot; inputMode=&quot;numeric&quot; … /&gt;<br>&lt;/form&gt;</pre><p>這也是 Chrome 和 Apple 對「兩段式登入」的官方建議寫法。有三個細節，都是踩過才知道的。隱藏要用 sr-only，不要用 hidden 或 display:none，因為看不見的欄位會被密碼管理員直接跳過，而 sr-only 是 1 像素大小，仍然算「看得見」。不要加 readOnly，唯讀欄位一樣不算進表單。最後補上 tabIndex={-1} 和 aria-hidden，讓鍵盤和螢幕閱讀器都碰不到它，對真人完全透明。</p><p>改完再跑一次分類器：</p><pre>otp: true<br>formType: 1<br>codeIsTreatedAsUsername: false</pre><p>表單型別變成 1，是一般表單，不再是登入表單；驗證碼欄也不再兼任帳號欄，和 Dropbox、Discord 那種會跳驗證碼的頁面是同一個形狀了。部署之後：</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/938/1*Y4wxi_gNHj8N2W6MNXHmWw.png" /><figcaption>終於。</figcaption></figure><h3>順手學到的兩件事</h3><p>第一件，不要用程式幫驗證碼欄搶焦點。擴充功能和 Safari 都只在使用者自己點進欄位時才顯示建議，程式先把焦點搶走，之後再點也不會產生新的 focus 事件，建議就永遠不會出現。這個我在追主線的時候順手拿掉了。</p><p>第二件，設定驗證器的 QR code 要用 img ，並且提供 otpauth:// 連結。Safari 右鍵選單裡的「在密碼中設定驗證碼」只認真正的圖片，行內 SVG 不行；otpauth:// 連結在 Mac 或 iPhone 上，則會直接打開「密碼」App。這和這次的 bug 無關，但 Apple 使用者的設定流程會順很多。</p><h3>可重用的排錯順序</h3><p>把這次的過程整理成一份順序，下次遇到類似的事，可以直接照著走。</p><ol><li>先確認資料存在。同步、快取、權限那些，等資料確認過再猜。</li><li>確認是哪個程式在做事，從彈窗的長相就分得出來。</li><li>找對照組：寫法和你一樣卻能用的網站（Dropbox），還有結構不同卻也能用的網站（Discord）。兩個一放，「寫法」這一整類假設就能砍掉。</li><li>拿到對方的程式碼：擴充功能抓 CRX，網頁抓 bundle。壓縮過沒關係，關鍵字通常還在。</li><li>用對方的邏輯測自己的頁面，不要憑閱讀去推論。一次 evaluate 就能看到它腦中的分類結果。</li><li>測變體時要真的把頁面畫出來。我一開始是事後改 DOM 再問分類器，每種寫法都「正常」，因為快取是畫面出現的當下建的。把舊版檔案切回來重跑，才看到真實的結果。</li><li>改一行，再跑一次同樣的檢查。分類結果變成對照組的形狀，才部署給真人試。</li></ol><p>整件事追完，修改是一行 JSX。但如果沒有把擴充功能拆開來、拿它自己的分類器對照，我大概會在 maxlength 和 pattern 之間來回改上一整天，最後只能得出「Apple 就是不喜歡我」這種沒什麼用的結論。</p><p>驗證碼欄從頭到尾都寫對了，它只是被推上了一個不屬於它的位置。問題從來不在驗證碼欄，而是那張在 SPA 換場時，被悄悄拆掉的登入表單。</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/82/0*bSXjcSCusn9f1Ntp" /></figure><p><em>Originally published at </em><a href="https://swift.moe/zh-hant/icloud-passwords-otp-spa-login-form/"><em>https://swift.moe</em></a><em> on September 23, 2026.</em></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=a3288545b3c1" width="1" height="1" alt=""><hr><p><a href="https://medium.com/peter-pan-swift-ios-class/icloud-passwords-otp-spa-login-form-a3288545b3c1">iCloud 密碼不自動填驗證碼？問題出在 SPA 的登入表單</a> was originally published in <a href="https://medium.com/peter-pan-swift-ios-class">彼得潘的 Swift iOS / Flutter App 開發教室</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[不花一毛錢，用自己的網域收信寄信 Part 3：用 Brevo 補簽章，門牌終於對得起來]]></title>
            <description><![CDATA[<div class="medium-feed-item"><p class="medium-feed-image"><a href="https://medium.com/peter-pan-swift-ios-class/cloudflare-gmail-custom-domain-email-part-3-696432b2da4b?source=rss----863d0bb9772b---4"><img src="https://cdn-images-1.medium.com/max/896/1*_NgkxXa0Aj5I9f-UuAX1hA.png" width="896"></a></p><p class="medium-feed-snippet">Return-Path &#x9084;&#x5BEB;&#x8457; Gmail&#x3001;DKIM &#x6C92;&#x81EA;&#x5DF1;&#x7684;&#x7AE0;&#x3001;DMARC fail&#xFF1A;&#x63DB;&#x4E00;&#x9593;&#x90F5;&#x5C40;&#xFF08;Brevo&#xFF0C;&#x514D;&#x8CBB;&#xFF09;&#x4E09;&#x500B;&#x4E00;&#x8D77;&#x89E3;&#x3002;</p><p class="medium-feed-link"><a href="https://medium.com/peter-pan-swift-ios-class/cloudflare-gmail-custom-domain-email-part-3-696432b2da4b?source=rss----863d0bb9772b---4">Continue reading on 彼得潘的 Swift iOS / Flutter App 開發教室 »</a></p></div>]]></description>
            <link>https://medium.com/peter-pan-swift-ios-class/cloudflare-gmail-custom-domain-email-part-3-696432b2da4b?source=rss----863d0bb9772b---4</link>
            <guid isPermaLink="false">https://medium.com/p/696432b2da4b</guid>
            <category><![CDATA[technology]]></category>
            <category><![CDATA[productivity]]></category>
            <category><![CDATA[web-development]]></category>
            <category><![CDATA[programming]]></category>
            <category><![CDATA[email]]></category>
            <dc:creator><![CDATA[RuRu (Sandy)]]></dc:creator>
            <pubDate>Thu, 10 Sep 2026 09:44:28 GMT</pubDate>
            <atom:updated>2026-09-10T10:02:13.696Z</atom:updated>
        </item>
        <item>
            <title><![CDATA[#2定義statelessWidget製作APP畫面顯示文字 — 小智介紹]]></title>
            <link>https://medium.com/peter-pan-swift-ios-class/2%E5%AE%9A%E7%BE%A9statelesswidget%E8%A3%BD%E4%BD%9Capp%E7%95%AB%E9%9D%A2%E9%A1%AF%E7%A4%BA%E6%96%87%E5%AD%97-%E5%B0%8F%E6%99%BA%E4%BB%8B%E7%B4%B9-901d0d8de8d2?source=rss----863d0bb9772b---4</link>
            <guid isPermaLink="false">https://medium.com/p/901d0d8de8d2</guid>
            <dc:creator><![CDATA[Cwkao]]></dc:creator>
            <pubDate>Thu, 10 Sep 2026 09:44:09 GMT</pubDate>
            <atom:updated>2026-09-10T09:44:08.174Z</atom:updated>
            <content:encoded><![CDATA[<p>經過這堂課，學到了要如何在手機畫面上顯示字以後，看到老師在這個作業給的範例是介紹小王子，我想了一下，因為我喜歡寶可夢，因此就決定寫寶可夢動畫中有關小智的介紹，畢竟我開始接觸寶可夢也是從小智時期的動畫開始。</p><p>作品圖片:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/940/1*BIBXjGnpUyGJ3Gq4w7bNbA.png" /></figure><p>在這個作品中，我使用白色的字，且用藍色底，讓顏色在畫面中比較和諧。</p><p>雖然這次因為不熟，所以有些還是有問AI，不過能成功做出來我還是覺得很有成就感!</p><p>Gibhub程式碼連結:</p><p><a href="https://github.com/JustinLu98/flutter20260707hw.git">https://github.com/JustinLu98/flutter20260707hw.git</a></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=901d0d8de8d2" width="1" height="1" alt=""><hr><p><a href="https://medium.com/peter-pan-swift-ios-class/2%E5%AE%9A%E7%BE%A9statelesswidget%E8%A3%BD%E4%BD%9Capp%E7%95%AB%E9%9D%A2%E9%A1%AF%E7%A4%BA%E6%96%87%E5%AD%97-%E5%B0%8F%E6%99%BA%E4%BB%8B%E7%B4%B9-901d0d8de8d2">#2定義statelessWidget製作APP畫面顯示文字 — 小智介紹</a> was originally published in <a href="https://medium.com/peter-pan-swift-ios-class">彼得潘的 Swift iOS / Flutter App 開發教室</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Medium 發表前先看一眼這 6 個設定，尤其是發表後改不了的網址]]></title>
            <link>https://medium.com/peter-pan-swift-ios-class/medium-settings-before-publish-4ef0bf86be39?source=rss----863d0bb9772b---4</link>
            <guid isPermaLink="false">https://medium.com/p/4ef0bf86be39</guid>
            <category><![CDATA[writing-tips]]></category>
            <category><![CDATA[medium]]></category>
            <category><![CDATA[seo]]></category>
            <category><![CDATA[blogging]]></category>
            <category><![CDATA[publishing]]></category>
            <dc:creator><![CDATA[RuRu (Sandy)]]></dc:creator>
            <pubDate>Wed, 09 Sep 2026 14:26:18 GMT</pubDate>
            <atom:updated>2026-09-10T10:02:42.936Z</atom:updated>
            <content:encoded><![CDATA[<p>這篇是從我的部落格同步過來的，中間的廣告是 Medium 平台放的。想看沒有廣告的乾淨版，歡迎來我的部落格閱讀：<a href="https://swift.moe/medium-settings-before-publish/">https://swift.moe/medium-settings-before-publish/</a></p><p>我的文章都是先發在自己的部落格 swift.moe，再用 Medium 的 <a href="https://medium.com/p/a9f425dbc3a2">Import a story</a> 匯入。前幾天匯入了一篇，發表後開心地把 Medium 的網址複製出來要分享，結果貼出去的是這個東西：</p><p><a href="https://medium.com/%E5%BD%BC%E5%BE%97%E6%BD%98%E7%9A%84-swift-ios-app-%E9%96%8B%E7%99%BC%E6%95%99%E5%AE%A4/%E4%B8%8D%E8%8A%B1%E4%B8%80%E6%AF%9B%E9%8C%A2-%E7%94%A8%E8%87%AA%E5%B7%B1%E7%9A%84%E7%B6%B2%E5%9F%9F%E6%94%B6%E4%BF%A1%E5%AF%84%E4%BF%A1-part-1-cloudflare-%E8%BD%89%E5%AF%84-gmail-%E5%AF%84%E4%BF%A1-ba20a3ca01eb">https://medium.com/%E5%BD%BC%E5%BE%97%E6%BD%98%E7%9A%84-swift-ios-app-%E9%96%8B%E7%99%BC%E6%95%99%E5%AE%A4/%E4%B8%8D%E8%8A%B1%E4%B8%80%E6%AF%9B%E9%8C%A2-%E7%94%A8%E8%87%AA%E5%B7%B1%E7%9A%84%E7%B6%B2%E5%9F%9F%E6%94%B6%E4%BF%A1%E5%AF%84%E4%BF%A1-part-1-cloudflare-%E8%BD%89%E5%AF%84-gmail-%E5%AF%84%E4%BF%A1-ba20a3ca01eb</a></p><p>標題明明是「不花一毛錢，用自己的網域收信寄信 Part 1：Cloudflare 轉寄 + Gmail 寄信」，網址卻變成一長串 %E4%B8%8D%E8%8A%B1……。這不是亂碼，是 URL 的百分比編碼（percent-encoding）。網址只准用 ASCII 字元，中文字進不去，所以每個中文字會被拆成 UTF-8 的 3 個 byte，再各自寫成 %XX，一個中文字就變成 9 個字元。這篇的網址裡有 27 個中文字，就這樣膨脹成 243 個字元。瀏覽器的網址列會貼心地翻譯回中文給你看，一複製出來卻是原汁原味的編碼版，所以自己看的時候沒感覺，貼給別人才嚇一跳。</p><figure><img alt="上方是瀏覽器網址列顯示中文標題的 Medium 網址，下方是複製後貼出來的百分比編碼版本" src="https://cdn-images-1.medium.com/max/1024/1*thmSsAcofu849l8X-kuSew.png" /><figcaption>網址列是中文，複製出來卻是一串 %</figcaption></figure><p>更慘的是，查了 Medium 的官方說明才知道，網址只能在發表前自訂，發表後就沒得改了。</p><p>被這件事打到之後，我把 Medium 發表文章前該看的設定全部翻了一遍，發現按下 Publish 之前要注意的事，比我想像的多很多。這篇就把它們一次整理起來，也順便記下哪些發表後還能改、哪些按下去就定案了。</p><p>光用文字講不太好懂，所以我做了一件有點傻的事：把系列裡的 Part 3 刪掉，重新匯入、重新發表一次，一路把畫面拍下來。反正它是系列裡最年輕的一篇，最適合拿來當白老鼠。接下來的截圖，都是那一趟重發的路上順手拍的。</p><h3>設定藏在哪裡</h3><p>Medium 的發表相關設定分成兩個地方。一個是編輯器右上角的「⋯」選單，點進 More settings，會進到一頁叫 Story settings 的設定頁。這頁很長，剛點進去只看得到最上面，不過左邊有目錄，依序是 Story Preview、Author、Reader Interests、SEO Settings、Promotion、Content Licensing、Advanced Settings，點目錄會直接跳到那一區。這篇會用到的是 SEO Settings 和排在最下面的 Advanced Settings。另一個地方是按下右上角綠色 Publish 按鈕之後跳出的畫面，Story Preview、Topics、Publication 都在這裡。要注意 Story settings 最上面雖然也有 Story Preview，但那裡只是一張預覽卡片，看得到、改不了，真的要改得在 Publish 畫面。</p><figure><img alt="Medium 編輯器右上角展開的選單，其中有 More settings 選項" src="https://cdn-images-1.medium.com/max/1024/1*P7lo08k01mpsfcUq7tBANw.png" /><figcaption>編輯器右上角的 ⋯ 選單</figcaption></figure><figure><img alt="Medium 的 Story settings 頁面，左側目錄列出 Story Preview、Author、Reader Interests、SEO Settings、Promotion、Content Licensing、Advanced Settings，右側顯示 Story Preview 的預覽卡片" src="https://cdn-images-1.medium.com/max/947/1*LzXp6rwkJdVrF5KoQB_2Pw.png" /><figcaption>Story settings 頁面的最上方，左邊是各區塊的目錄</figcaption></figure><p>接下來就照這個順序一個一個看。</p><h3>1. Customize Story Link：自訂網址，發表後不能改</h3><p>在 Story settings 左邊的目錄點最下面的 Advanced Settings，第一個就是 Customize Story Link，有 Automatic 和 Custom 兩個選項，預設是 Automatic，也就是拿標題當網址。點 Custom 之後，下面會多出一個輸入框和發表後的網址預覽，這時候你會發現，輸入框裡預設塞的就是那一串 % 編碼，中文標題已經被轉換過了，% 大隊就是從這裡來的。</p><figure><img alt="Medium Advanced Settings 的 Customize Story Link 區塊，選了 Custom 之後，輸入框裡預先填入中文標題轉成的百分比編碼，上方的網址預覽也是一長串 %" src="https://cdn-images-1.medium.com/max/946/1*L5EluqnU63zaOBtRBf1oVA.png" /><figcaption>剛點 Custom 時，欄位裡預設就是那串 % 編碼</figcaption></figure><p>要改的是下面那個輸入框，把框裡那串編碼全部清掉。清空的瞬間，上面的預覽會只剩結尾那串 ID，這時候再把英文 slug 打進去，例如 cloudflare-gmail-custom-domain-email-part-3，預覽就會跟著變成英文，看順眼了再按 Save story link。這時候預覽的前半段還是 @你的帳號，等文章投進 publication 發表之後，前半段才會換成 publication 的網址，slug 和結尾那串 ID 不會變。欄位下面那行小字也寫得很明白：You can only customize your link before publishing。</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/924/1*MkkrQsvLJ5odO55MVoH-Pg.png" /><figcaption>換成英文 slug 之後的網址預覽</figcaption></figure><p>slug 怎麼取沒有標準答案，我自己的習慣是全部小寫、單字之間用 — 連接，放這篇文章的關鍵字，讓人看網址就大概知道在講什麼。不用太長，「Part 3」這種資訊留在標題就好。如果文章是從自己的部落格匯入的，直接沿用部落格的 slug 最省事，兩邊網址一致，自己也好記。</p><p>要特別注意的是，官方說明寫得很清楚，自訂連結只能在發表前設定。發表後再進 Story settings，Advanced Settings 裡就只剩 Customize Canonical Link，Story Link 那一格整個消失。而且發表後修改標題，網址也不會跟著變，網址是發表當下就凍結的，標題打錯字，錯字會永遠留在網址裡。就算自訂了英文網址，Medium 還是會在結尾加上一串 12 碼的 ID（例如 -ba20a3ca01eb），拿不到完全乾淨的網址，這是正常的。</p><p>還有一個容易忽略的地方。如果文章是發在 publication 裡，網址前半段是 publication 的網址，像我發文的「彼得潘的 Swift iOS / Flutter App 開發教室」，網址還是當初的 彼得潘的-swift-ios-app-開發教室，後來名字加上 Flutter 也沒跟著變，中文的部分一樣會被編碼。這段不是在文章裡改的，要由 publication 的擁有者寫信給 Medium Support 申請更改，提供目前的網址和想改成的網址，而且新網址不能已經被別的 publication 用走。</p><p>已經發表、來不及改的文章，有一個補救辦法。Medium 支援 medium.com/p/文章ID 這種短網址，把結尾那串 ID 接上去就會轉到文章，例如 <a href="https://medium.com/p/ba20a3ca01eb">https://medium.com/p/ba20a3ca01eb</a> 。分享的時候至少不用貼一整串 %。轉址快到來不及看清楚，一眨眼網址列就變回原本那串了。</p><h3>2. Customize Canonical Link：告訴 Google 正本在哪</h3><p>同樣在 Advanced Settings，下面還有一個 This story was originally published elsewhere 的勾選框。Canonical link（標準網址）是給搜尋引擎看的。同一篇文章同時放在自己的部落格和 Medium 時，Google 會看到兩份一模一樣的內容，不知道該把排名給誰，甚至可能當成重複內容處理。設了 canonical link，就等於跟 Google 說正本在那邊，Medium 這份是副本。</p><p>所以只有文章先在別的地方發表過才需要填，像是自己的部落格或公司官網。如果是首發在 Medium，這個選項完全不用動。</p><p>我的做法是用 Medium 的 <a href="https://medium.com/p/a9f425dbc3a2">Import a story</a>（在 <a href="https://medium.com/p/import">medium.com/p/import</a>）直接從部落格的網址匯入，Medium 會自動把來源網址填進 canonical link，不用自己動手。如果是手動把內容貼進新草稿，就要自己來：勾選 This story was originally published elsewhere，貼上原文網址（像這篇就是 <a href="https://swift.moe/zh-hant/cloudflare-gmail-custom-domain-email-part-3/">https://swift.moe/zh-hant/cloudflare-gmail-custom-domain-email-part-3/</a> ），按 Save canonical link。發表後也可以改，從 Edit story 進去走同樣的路徑，改完記得按 Publish 讓變更生效。</p><p>想確認有沒有設成功，可以在文章頁面按右鍵「檢視網頁原始碼」，搜尋 canonical，第一個結果就是你設的網址。</p><h3>3. SEO Settings：SEO Title 與 SEO Description</h3><p>Story settings 目錄裡的 SEO Settings 有兩個欄位，決定文章在 Google 搜尋結果裡長什麼樣子，SEO Title 會取代標題，SEO Description 會取代副標。Medium 把規則直接寫在欄位上方：SEO Title 超過 60 個字元會被截斷，40 到 50 個字元、含有大家常搜的字，點閱率最好；SEO Description 建議 140 到 156 個字元，要有關鍵字、要能摘要文章。欄位旁邊會顯示目前的字元數，超過就變紅字。</p><p>這兩格 Medium 都會先幫你填好，但填得不太好。SEO Title 預設是文章標題再接上「| by 你的名字 | Medium」，像我這篇一接就是 71 個字元，直接紅字，前面塞了一整個中文標題，後面又掛著作者和平台名，真正該讓人搜到的關鍵字反而沒有位置。SEO Description 預設更慘，Medium 直接抓內文開頭。我的文章開頭有一段固定的說明，提醒讀者這篇是從部落格同步過來的、中間的廣告是 Medium 放的，於是搜尋結果會看到的摘要就是這段，198 個字元，整段在講廣告和部落格連結，一個字都沒提到文章在解決什麼。</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/941/1*5yXgQmzStWpEL6K0gcrC-w.png" /><figcaption>Medium 預設填好的 SEO Title 和 Description，兩個都是紅字</figcaption></figure><p>所以我會自己改。這篇的 SEO Title 改成「Gmail 自訂網域補 DKIM Part 3」，23 個字元，把 Gmail、自訂網域、DKIM 這幾個大家真的會拿去搜的字放在最前面。中文字比英文字母寬，Google 實際顯示得比字元數少，中文大約 30 個字就該收尾。SEO Description 改成「Part 1 老實列出三個限制：Gmail 帳號藏不住、沒有數位簽章、容易被當垃圾信。這篇不花一毛錢，用 Brevo 免費 SMTP 幫自訂網域補上 DKIM 和 DMARC，順便拆解新手常踩的 brevosend.com 陷阱，另外也附上一個留在 Cloudflare 一家處理的付費選項。」，146 個字元，剛好落在建議範圍內。寫法是先講問題再講解法，最重要的東西擺在前 80 個字元內，因為後半段在 Google 上常常看不到。這兩個欄位發表後都可以改，所以先填個大概也沒關係。</p><figure><img alt="Medium SEO Settings 頁面，SEO Title 改為 Gmail 自訂網域補 DKIM Part 3，SEO Description 改為以三個限制開頭的文章摘要，字元數都在建議範圍內" src="https://cdn-images-1.medium.com/max/934/1*jLwq9yXOisTkRCrYFFWTuA.png" /><figcaption>改好的 SEO Title 和 Description</figcaption></figure><h3>4. Reader Interests（Topics）：告訴 Medium 這篇該推給誰</h3><p>按下 Publish 之後畫面右邊的 Topics（Story settings 目錄裡叫它 Reader Interests），最多可以加 5 個。Medium 會根據 topics 決定把文章推給對哪些主題有興趣的讀者，文章也會出現在對應的主題頁面。我通常會放幾個具體的（像 Swift、iOS、Xcode、SwiftUI），再配一兩個範圍大的（像 Programming、Mobile Development），具體的幫你找到對的人，大的幫你增加曝光。發表後可以改，Edit story 之後按 Save and publish 的時候會再出現同樣的畫面。</p><figure><img alt="Medium 發表畫面的 Add a topic 區塊，已加入五個主題標籤" src="https://cdn-images-1.medium.com/max/929/1*dEzA_134tT2x8mXTahOibA.png" /><figcaption>Add a topic，最多 5 個</figcaption></figure><h3>5. Story Preview：preview title、preview subtitle、preview image</h3><p>Publish 畫面的左半邊是 Story Preview，也就是文章在 Medium 首頁、你的個人頁、訂閱者信箱裡顯示的樣子。Story settings 一進來最上面也有一張 Story Preview 的卡片，模擬文章出現在列表裡的樣子，不過那張卡片只能看不能改，要改還是得回到 Publish 畫面。畫面上方會先看到 preview image，預設是文章裡的第一張圖，滑鼠移上去會出現 Change preview image 和 Adjust image，可以換成別張或調整裁切；文章裡一張圖都沒有的話這格就是空的，Medium 會提醒你加一張。下面兩格是 preview title 和 preview subtitle，上限分別是 100 和 140 個字元，欄位旁邊會顯示目前用了多少。preview title 預設帶入文章標題，清空的話會紅字提醒 Please enter a title，這格是必填的；preview subtitle 預設是副標，沒有副標就去抓內文。Medium 在下面也特別註明，這裡改的只是文章對外顯示的樣子，不會動到文章本身。</p><p>其中 preview subtitle 最容易被忽略。如果寫文章時沒有用副標格式，Medium 會自己去抓內文，像我這篇就抓到開頭那段同步說明，卡片上的第二行寫著「中間的廣告是 Medium 平台放的」，讀者在首頁看到的第一句話就這樣浪費掉了。在這裡改成一句能說服人點進來的話，不會影響文章內文。發表後一樣可以改。</p><figure><img alt="Medium Story settings 頁面的 Story Preview 卡片，標題下方顯示的是 Medium 自動擷取的內文開頭，右側縮圖是文章的第一張圖" src="https://cdn-images-1.medium.com/max/920/1*Ds3rbS4daWAYe_nA6KxHPQ.png" /><figcaption>Story settings 裡的預覽卡片，副標抓到了開頭的同步說明</figcaption></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1010/1*gxb6aV7CaY8xV6jKfVn2Wg.png" /><figcaption>在 Publish 畫面改好的 preview title 和 subtitle</figcaption></figure><h3>6. Publish 畫面的其他選項</h3><p>同一個畫面的右邊，Topics 下面還有幾個東西順便看一下。Publication 這格寫著 Submit your story to connect with community，要發在 publication 的文章就是在這裡點 Submit。點下去會跳出 Submit to a publication 的視窗，Contributing 分頁列的是你已經是作者的 publication，Following 分頁則是你追蹤、而且目前開放投稿的，點一下要投的那個就好。</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*KYMAD7SFT0DKY8sIeV9-7Q.png" /><figcaption>Publication 這一格，點 Submit 投稿</figcaption></figure><figure><img alt="Medium 的 Submit to a publication 視窗，Contributing 分頁下顯示「彼得潘的 Swift iOS / Flutter App 開發教室」的卡片" src="https://cdn-images-1.medium.com/max/1024/1*Sy3c6Kh6S8UtILBFXc4GBw.png" /><figcaption>Submit to a publication 視窗，Contributing 分頁列出可以投稿的 publication</figcaption></figure><p>以我自己為例，我是彼得潘的學生，他會鼓勵學生把文章發到他的 publication「彼得潘的 Swift iOS / Flutter App 開發教室」，被加為作者之後，它就會出現在 Contributing 分頁。點了卡片之後，文章不是馬上發表，而是送到 publication 的編輯那邊審核，編輯發表之後才會上線，網址前半段也是這時候才換成 publication 的。因為真正按下發表的是編輯，Story Link 一定要在送出之前就設好，送出去就來不及了。送出後如果反悔，文章選單裡的 Withdraw submission 可以撤回。</p><p>再往下是 Notify your subscribers，預設是勾起來的，發表時會寄信通知有訂閱你的讀者，如果只是小修重發、不想打擾大家，可以取消。最下面就是黑色的 Publish 按鈕，旁邊的 Schedule for later 可以排程在指定時間發表。另外如果想發表但不想出現在首頁動態和搜尋裡，可以把文章設成 unlisted（編輯模式的「⋯」選單裡的 Manage unlisted setting），有連結的人還是看得到，適合只想分享給特定人看的文章。</p><h3>按下 Publish 之後就改不了的事</h3><p>整理一下，發表後就沒辦法動的有四件事。第一是網址，發表後不能自訂，改標題也不會跟著變。第二是退回草稿，Medium 官方說明寫明，發表後的文章不能退回草稿狀態，只能設成 unlisted 或刪除（文章選單的 Delete story，或 Story settings 最下面那顆紅色按鈕都可以），想重新來過的話，只能把內容複製到新草稿再發表一次，原本的拍手、回應、閱讀數都不會跟過去。文章選單裡的 Withdraw submission 看起來很像退稿，但照官方說明，它做的是把文章從 publication 抽出來，文章本身還是已發表狀態，只是搬回你自己的個人頁，也不是退回草稿。第三是發表日期，Medium 沒有提供修改的功能。倒是用 Import a story 匯入的文章，Medium 會直接抓原文網頁的發表日期，所以從部落格匯入的話，兩邊日期會自動一致；手動貼上的，日期就是按下 Publish 的那一刻。第四是 publication 的網址，不是在文章裡改的，要由擁有者向 Medium Support 申請。</p><figure><img alt="Medium 已發表文章右上角展開的選單，選項有 Edit story、Withdraw submission、Story settings、Story stats、Delete story 等，沒有退回草稿的選項" src="https://cdn-images-1.medium.com/max/1024/1*cIw2NISeEAwXN2E2bndnVg.png" /><figcaption>已發表文章的 ⋯ 選單，沒有 Unpublish</figcaption></figure><p>其他的像 canonical link、SEO Title、SEO Description、topics、preview、unlisted，都可以事後從 Edit story 進去改。改完一定要按 Save and publish 才會生效；改到一半後悔了，「⋯」選單裡有 Cancel editing，可以把還沒發布的修改全部丟掉。</p><figure><img alt="Medium 編輯已發表文章的畫面，右上角顯示 Save and publish 按鈕，展開的選單中有 Cancel editing 選項" src="https://cdn-images-1.medium.com/max/1024/1*LgW6sOoiZ3mJLTtwpBdMYg.png" /><figcaption>編輯已發表文章時的 Save and publish 與 Cancel editing</figcaption></figure><h3>我的發表前檢查清單</h3><p>現在每次按 Publish 前，我都會先把這幾件事看過一遍：</p><ul><li>Story Link 改成英文了嗎？</li><li>canonical link 填了嗎？（用 Import a story 匯入的會自動填好，確認一下就行）</li><li>SEO Title 有把關鍵字放最前面嗎？SEO Description 寫了嗎？</li><li>Topics 加滿 5 個了嗎？</li><li>Preview 的標題、副標、圖片看過了嗎？</li><li>要發在 publication 的話，Submit 了嗎？</li></ul><p>這份清單，一半是寫給大家，一半是寫給下次又忘記的自己。這些設定藏得深，不特別記著的話，很容易按完 Publish 才想起來。</p><p>至於那篇被我刪掉重發的 Part 3，新的網址終於是看得懂的英文，分享出去不用再多解釋。其他幾篇就先放著，反正 canonical 都指回部落格了，Google 認得路，只是人認不得而已。下次按 Publish 之前，我會先去看一眼 Story Link。</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/82/0*n4Zim98rnOU7gVXv" /></figure><p><em>Originally published at </em><a href="https://swift.moe/zh-hant/medium-settings-before-publish/"><em>https://swift.moe</em></a><em> on September 9, 2026.</em></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=4ef0bf86be39" width="1" height="1" alt=""><hr><p><a href="https://medium.com/peter-pan-swift-ios-class/medium-settings-before-publish-4ef0bf86be39">Medium 發表前先看一眼這 6 個設定，尤其是發表後改不了的網址</a> was originally published in <a href="https://medium.com/peter-pan-swift-ios-class">彼得潘的 Swift iOS / Flutter App 開發教室</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[把部落格文章同步到 Medium，又不傷 SEO：Import a story 和 canonical]]></title>
            <link>https://medium.com/peter-pan-swift-ios-class/%E6%8A%8A%E9%83%A8%E8%90%BD%E6%A0%BC%E6%96%87%E7%AB%A0%E5%90%8C%E6%AD%A5%E5%88%B0-medium-%E5%8F%88%E4%B8%8D%E5%82%B7-seo-import-a-story-%E5%92%8C-canonical-a9f425dbc3a2?source=rss----863d0bb9772b---4</link>
            <guid isPermaLink="false">https://medium.com/p/a9f425dbc3a2</guid>
            <category><![CDATA[中文-繁體]]></category>
            <category><![CDATA[seo]]></category>
            <category><![CDATA[wordpress]]></category>
            <category><![CDATA[blogging]]></category>
            <category><![CDATA[medium]]></category>
            <dc:creator><![CDATA[RuRu (Sandy)]]></dc:creator>
            <pubDate>Sun, 06 Sep 2026 02:21:57 GMT</pubDate>
            <atom:updated>2026-09-10T10:03:39.557Z</atom:updated>
            <content:encoded><![CDATA[<p>這篇是從我的部落格同步過來的，中間的廣告是 Medium 平台放的。想看沒有廣告的乾淨版，歡迎來我的部落格閱讀：<a href="https://swift.moe/zh-hant/sync-blog-to-medium-canonical/">https://swift.moe/zh-hant/sync-blog-to-medium-canonical/</a></p><p>自己的部落格是家，Medium 是人多的廣場。文章想兩邊都放很合理，但直接複製貼上會留下一個小問題：Google 會看到兩篇一模一樣的文章，不一定知道哪篇是正本，搜尋排名的功勞可能算給 Medium 而不是你的部落格。這篇講怎麼同步得漂亮：讓 Medium 的讀者看得到文章，搜尋引擎又知道正本在你家。</p><h3>關鍵字只有一個：canonical</h3><p>canonical 是網頁原始碼裡的一行標記，長這樣：</p><pre>&lt;link rel=&quot;canonical&quot; href=&quot;https://你的部落格/文章網址/&quot;&gt;</pre><p>它放在網頁的 &lt;head&gt; 區塊，讀者看不到，只有搜尋引擎的爬蟲會讀。意思是「這一頁的正本在那個網址」。Medium 那篇加了這行，Google 就會把兩篇當成同一篇，排名的功勞記在你的部落格，Medium 那篇只是副本，不會跟你自己競爭，也不會被當成抄襲。</p><figure><img alt="Medium 文章原始碼的示意圖，JSON 資料裡的 canonicalUrl 欄位用黃色螢光筆標起來，值是 https://swift.moe/zh-hant/cloudflare-gmail-custom-domain-email-part-1/，旁邊一張粉紅色便利貼寫著「正本在這裡」，右上角的搜尋列顯示 canonical 3/3" src="https://cdn-images-1.medium.com/max/1024/1*DYJlckzrKQuOURnMxaHOiQ.png" /><figcaption>原始碼裡的 canonicalUrl，指回自己的部落格就對了</figcaption></figure><h3>方法一：用 Import a story 匯入（推薦）</h3><p>Medium 內建的匯入工具會自動幫你加 canonical，還會把發佈日期回填成原文的日期。</p><ol><li>在 Medium 首頁點開左上角的側邊欄，選 <strong>Stories</strong>。</li><li>Stories 頁面右上角有 <strong>Import a story</strong>，點進去。找不到的話直接打網址 <a href="https://medium.com/p/import">https://medium.com/p/import</a> 也會到同一個地方。</li></ol><figure><img alt="Medium 的 Stories 頁面，左上角的漢堡選單、側邊欄的 Stories 和右上角的 Import a story 按鈕各用紅框標出，依序標示 1、2、3" src="https://cdn-images-1.medium.com/max/1024/1*0m9bxIYFTXqBEw0NhE2VlA.png" /><figcaption>Stories 頁面右上角的 Import a story</figcaption></figure><p>3. 回到部落格，把文章的網址複製起來。</p><figure><img alt="Chrome 打開 swift.moe 上的文章「不花一毛錢，用自己的網域收信寄信 Part 2：讓自訂網域的信也有大頭貼」，網址列和文章標題各用紅框標出，中間一個黃色箭頭從標題指向網址列" src="https://cdn-images-1.medium.com/max/961/1*g_6pRtBZBEozvo1lWuvV_g.png" /><figcaption>先把部落格的文章網址複製起來</figcaption></figure><p>4. 貼上網址，按 <strong>Import</strong>。</p><figure><img alt="Medium 的 See your story on Medium 匯入頁面，網址欄位裡貼著 swift.moe 的文章連結並用紅框標出，下方是綠色外框的 Import 按鈕，最下面寫著 Please only import content that you own" src="https://cdn-images-1.medium.com/max/1024/1*5-ufg2gJ6pQj4L1wUWfWyA.png" /><figcaption>貼上網址，按 Import</figcaption></figure><p>5. 按 <strong>See your story</strong>，會得到一篇草稿，圖片和段落大多會自動帶進來，順一下版面。</p><figure><img alt="Medium 顯示 Imported the story 的說明畫面，列出三個步驟：調整文字格式、調整圖片版面、按 Publish 發佈，最下方的 See your story 按鈕用紅框標出" src="https://cdn-images-1.medium.com/max/1024/1*DX0jvvQFgva4oSU3tAtosA.png" /><figcaption>按 See your story 去看草稿</figcaption></figure><figure><img alt="Medium 編輯器裡的草稿，標題是「不花一毛錢，用自己的網域收信寄信 Part 2：讓自訂網域的信也有大頭貼」，下面有引用段落、Part 1 的連結和一張 Gmail 截圖，右上角顯示 Draft、Saved 和綠色的 Publish 按鈕" src="https://cdn-images-1.medium.com/max/1024/1*TyZBCjTHsWq5sPNkbYlrsw.png" /><figcaption>匯入後的草稿，段落和圖片都帶進來了</figcaption></figure><p>6. 按 <strong>Publish</strong> 發佈。</p><p>匯入的文章 Medium 會在文末自動加一行小字「Originally published at 網址 on 日期」，那是給讀者看的，canonical 是給搜尋引擎看的，兩件事分開，但匯入會一次幫你做好。</p><figure><img alt="Medium 草稿的結尾，結尾段落下方有一張小綠綠貼圖、三個點的分隔線，以及斜體的 Originally published at https://swift.moe on September 4, 2026.，整塊用紅框標出" src="https://cdn-images-1.medium.com/max/1024/1*ckweLLadZG-jguq1Pe8QsA.png" /><figcaption>文末自動多出來的 Originally published at</figcaption></figure><p>小提醒：只能匯入你自己有權利發佈的文章。另外匯入工具偶爾會抓不到某些網站的內容，出現空白頁或錯誤時，就改用方法二。</p><h3>方法二：已經貼上發佈了，事後補 canonical</h3><p>如果文章已經用複製貼上的方式發了，不用刪掉重來，Medium 可以手動指定 canonical。設定藏得有點深，選單裡不會直接看到 canonical 這個字，照著走就好：</p><ol><li>打開那篇文章，右上角三個點選 <strong>Edit story</strong>。</li></ol><figure><img alt="Medium 上已發佈的文章「讓你的 Terminal 變可愛吧」，右上角三個點的選單展開，Edit story 用紅框標出，旁邊有一個黃色箭頭" src="https://cdn-images-1.medium.com/max/1024/1*TVbLG-LvgdgkMSWPq_nUOA.png" /><figcaption>右上角三個點，選 Edit story</figcaption></figure><p>2. 進入編輯模式後，再點一次右上角的三個點，選 <strong>More settings</strong>，會開出一頁完整的設定頁。</p><figure><img alt="Medium 編輯模式的畫面，右上角三個點的選單展開，清單最下方的 More settings 用紅框標出，上方有一個黃色箭頭往下指" src="https://cdn-images-1.medium.com/max/1024/1*GRQJlRxEzm9YyOKqRD66Cg.png" /><figcaption>再點一次三個點，選 More settings</figcaption></figure><p>3. 往下捲到 <strong>Advanced settings</strong>，勾選 <strong>This story was originally published elsewhere</strong>，點右邊的 Edit canonical link 按鈕，貼上部落格的原文網址，按 <strong>Save canonical link</strong> 就完成了。</p><figure><img alt="Medium 的 More settings 頁面 Advanced Settings 區塊，依序標示 1 到 4：Advanced Settings 標題、已勾選的 This story was originally published elsewhere、填著 https://swift.moe/make-your-terminal-adorable/ 的網址欄位、綠色的 Save canonical link 按鈕" src="https://cdn-images-1.medium.com/max/1024/1*5_VEBpgR4QCVD1jNtSQVxg.png" /><figcaption>勾選後貼上原文網址，按 Save canonical link</figcaption></figure><p>效果跟匯入的一模一樣。</p><h3>確認有沒有成功</h3><p>在 Medium 那篇文章的頁面按 Cmd + Option + U 打開原始碼（Chrome 直接可用，Safari 要先在設定的 Advanced 分頁把 Develop 選單打開），或是在頁面上按右鍵選 View Page Source，是同一個東西。</p><figure><img alt="在 Medium 文章頁面上按右鍵跳出的 Chrome 選單，最下方的 View Page Source 反白標示" src="https://cdn-images-1.medium.com/max/1024/1*HqsJF5hzQFYJYXZL6Sv7zw.png" /><figcaption>按右鍵選 View Page Source 也可以</figcaption></figure><p>打開之後按 Cmd + F 搜尋 canonical。登入狀態下看到的會是 canonicalUrl 這個欄位，後面的網址是你的部落格就成功了；如果是 Medium 自己的網址，代表沒設到。網址裡的 \u002F 就是斜線，原始碼的寫法而已，不用理它。原始碼全部擠在一行不好找的話，全選複製貼到 VS Code 再搜尋會清楚很多，下面這張就是這樣看的。</p><figure><img alt="貼進 VS Code 的 Medium 原始碼，搜尋列輸入 canonical 顯示 4 of 4，第 7251 行的 canonicalUrl 欄位用紅框標出，值是 https:\u002F\u002Fswift.moe\u002Fmake-your-terminal-adorable\u002F" src="https://cdn-images-1.medium.com/max/1024/1*vX4nbuHFeqkHmN47r7gH3A.png" /><figcaption>canonicalUrl 已經指向部落格的原文網址</figcaption></figure><h3>順手把 Medium 的 SEO 設定填好</h3><p>方法二走進去的那頁 More settings，除了 Advanced settings，還有 <strong>Reader Interests</strong> 和 <strong>SEO settings</strong> 兩區，不管用哪個方法同步，都可以順手填一下。路徑一樣：編輯模式右上角三個點，選 More settings。</p><p>Reader Interests 就是 Topics，最多五個，發佈視窗裡也能設定，發佈後還能從編輯模式三個點選單的 <strong>Change topics</strong> 改。挑「已經存在、有人追蹤」的標籤，打字時會跳出建議，選有建議的；中文文章可以留一個給「繁體中文」，Medium 上的中文讀者多半靠它找文章。</p><figure><img alt="Medium More settings 頁面的 Overview 區塊，顯示 RuRu (Sandy) @sandyru 的個人資料和 Partner Program 狀態，下方的 Reader Interests 區塊用紅框標出，裡面有 Terminal、Zsh、Iterm2、Macos、Productivity 五個標籤" src="https://cdn-images-1.medium.com/max/1024/1*AYFpBlpI5X4hwasptJMPQA.png" /><figcaption>Reader Interests 就是 Topics，最多五個</figcaption></figure><p>SEO settings 有兩個欄位，都有長度限制，而且是算字元數，中文一個字算一個，很容易爆。</p><ul><li><strong>SEO Title</strong>：Medium 會自動接上「| by 你的名字 | Medium」，大約 27 個字元，所以自己的部分壓在 33 個字元以內，整體才不會超過 60 被截斷。文章如果是發在出版品裡，接上的會是「| 出版品名稱」，像我的就是「| 彼得潘的 Swift iOS / Flutter App 開發教室」，更長，自己的部分要壓得更短。</li><li><strong>SEO Description</strong>：建議 140 到 156 個字元，最重要的一句放最前面，Google 截掉後半也不影響。</li></ul><figure><img alt="Medium More settings 頁面的 SEO Settings 區塊，標示 1 的 SEO Title 欄位上方顯示 Title preview (92)，標示 2 的 SEO Description 欄位上方顯示 Description (143)，兩個輸入欄位都用紅框標出，右邊各有綠色的 Save 按鈕" src="https://cdn-images-1.medium.com/max/1024/1*3JG__vLb8GzDy9Zu2KY_7g.png" /><figcaption>SEO Title 和 SEO Description，括號裡是字元數</figcaption></figure><p>發佈視窗裡的 Story preview 標題和副標是另一回事，那是給 Medium 站內、訂閱者信件和社群分享卡看的；SEO settings 是給 Google 看的，兩邊可以不一樣。</p><h3>文章開頭加一句同步聲明</h3><p>Medium 會在文章中穿插廣告，那是平台的機制，讀者不一定分得清楚。開頭加一句話把事情講清楚，順便邀請讀者回家：</p><blockquote><em>這篇是從我的部落格同步過來的，中間的廣告是 Medium 平台放的。想看沒有廣告的乾淨版，歡迎來我的部落格閱讀：（原文網址）</em></blockquote><figure><img alt="Medium 草稿的開頭，標題下方一段文字用紅框標出：這篇是從我的部落格同步過來的，中間的廣告是 Medium 平台放的。想看沒有廣告的乾淨版，歡迎來我的部落格閱讀，後面接著 swift.moe 的文章連結" src="https://cdn-images-1.medium.com/max/1024/1*wqYU0dqRPj66EoIL-T5hmQ.png" /><figcaption>開頭的同步聲明，附上原文連結</figcaption></figure><h3>兩個小提醒</h3><p>canonical 是給搜尋引擎的建議，不是命令。Google 幾乎都會照做，但偶爾還是會在某些搜尋結果裡秀出 Medium 那篇，這是正常的，功勞還是記在你的部落格。</p><p>canonical 只處理搜尋引擎那一邊，Medium 站內的讀者還是會在 Medium 上讀你的文章，所以開頭那句「歡迎來我的部落格」還是有用，兩個機制各管一邊。</p><h3>結尾</h3><p>做到這裡，文章就可以放心出門了。用匯入的話，Medium 會幫它別好一張寫著「我家在這裡」的名牌；已經貼上去的，回去補一張 canonical 也一樣；補完打開原始碼看一眼，確認名牌沒掉。</p><p>然後它就可以在廣場上被很多人看見，認識新朋友，哪天 Google 問起「這篇是誰家的」，答案還是你家。</p><p>照著做的過程中卡住的話，留言告訴我，我們一起看看。</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/82/0*lXTJ5maOUWG3i91U" /></figure><p><em>Originally published at </em><a href="https://swift.moe/zh-hant/sync-blog-to-medium-canonical/"><em>https://swift.moe</em></a><em> on September 5, 2026.</em></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=a9f425dbc3a2" width="1" height="1" alt=""><hr><p><a href="https://medium.com/peter-pan-swift-ios-class/%E6%8A%8A%E9%83%A8%E8%90%BD%E6%A0%BC%E6%96%87%E7%AB%A0%E5%90%8C%E6%AD%A5%E5%88%B0-medium-%E5%8F%88%E4%B8%8D%E5%82%B7-seo-import-a-story-%E5%92%8C-canonical-a9f425dbc3a2">把部落格文章同步到 Medium，又不傷 SEO：Import a story 和 canonical</a> was originally published in <a href="https://medium.com/peter-pan-swift-ios-class">彼得潘的 Swift iOS / Flutter App 開發教室</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[不花一毛錢，用自己的網域收信寄信 Part 2：讓 Gmail 自訂網域寄出的信也顯示大頭貼]]></title>
            <description><![CDATA[<div class="medium-feed-item"><p class="medium-feed-image"><a href="https://medium.com/peter-pan-swift-ios-class/%E4%B8%8D%E8%8A%B1%E4%B8%80%E6%AF%9B%E9%8C%A2-%E7%94%A8%E8%87%AA%E5%B7%B1%E7%9A%84%E7%B6%B2%E5%9F%9F%E6%94%B6%E4%BF%A1%E5%AF%84%E4%BF%A1-part-2-%E8%AE%93-gmail-%E8%87%AA%E8%A8%82%E7%B6%B2%E5%9F%9F%E5%AF%84%E5%87%BA%E7%9A%84%E4%BF%A1%E4%B9%9F%E9%A1%AF%E7%A4%BA%E5%A4%A7%E9%A0%AD%E8%B2%BC-6454eb2edf19?source=rss----863d0bb9772b---4"><img src="https://cdn-images-1.medium.com/max/1024/0*LT61JsaFiGz9_Khm.png" width="1024"></a></p><p class="medium-feed-snippet">&#x81EA;&#x8A02;&#x7DB2;&#x57DF;&#x5BC4;&#x51FA;&#x7684;&#x4FE1;&#x9084;&#x662F;&#x7070;&#x7070;&#x7684;&#x9810;&#x8A2D;&#x982D;&#x50CF;&#xFF1F;&#x628A;&#x5730;&#x5740;&#x52A0;&#x9032; Google &#x5E33;&#x865F;&#x7684;&#x66FF;&#x4EE3;&#x4FE1;&#x7BB1;&#xFF0C;&#x5927;&#x982D;&#x8CBC;&#x5C31;&#x6703;&#x8DDF;&#x8457;&#x51FA;&#x73FE;&#x3002;</p><p class="medium-feed-link"><a href="https://medium.com/peter-pan-swift-ios-class/%E4%B8%8D%E8%8A%B1%E4%B8%80%E6%AF%9B%E9%8C%A2-%E7%94%A8%E8%87%AA%E5%B7%B1%E7%9A%84%E7%B6%B2%E5%9F%9F%E6%94%B6%E4%BF%A1%E5%AF%84%E4%BF%A1-part-2-%E8%AE%93-gmail-%E8%87%AA%E8%A8%82%E7%B6%B2%E5%9F%9F%E5%AF%84%E5%87%BA%E7%9A%84%E4%BF%A1%E4%B9%9F%E9%A1%AF%E7%A4%BA%E5%A4%A7%E9%A0%AD%E8%B2%BC-6454eb2edf19?source=rss----863d0bb9772b---4">Continue reading on 彼得潘的 Swift iOS / Flutter App 開發教室 »</a></p></div>]]></description>
            <link>https://medium.com/peter-pan-swift-ios-class/%E4%B8%8D%E8%8A%B1%E4%B8%80%E6%AF%9B%E9%8C%A2-%E7%94%A8%E8%87%AA%E5%B7%B1%E7%9A%84%E7%B6%B2%E5%9F%9F%E6%94%B6%E4%BF%A1%E5%AF%84%E4%BF%A1-part-2-%E8%AE%93-gmail-%E8%87%AA%E8%A8%82%E7%B6%B2%E5%9F%9F%E5%AF%84%E5%87%BA%E7%9A%84%E4%BF%A1%E4%B9%9F%E9%A1%AF%E7%A4%BA%E5%A4%A7%E9%A0%AD%E8%B2%BC-6454eb2edf19?source=rss----863d0bb9772b---4</link>
            <guid isPermaLink="false">https://medium.com/p/6454eb2edf19</guid>
            <category><![CDATA[cloudflare]]></category>
            <category><![CDATA[gmail]]></category>
            <category><![CDATA[google]]></category>
            <category><![CDATA[email]]></category>
            <category><![CDATA[custom-domain]]></category>
            <dc:creator><![CDATA[RuRu (Sandy)]]></dc:creator>
            <pubDate>Sat, 05 Sep 2026 10:26:10 GMT</pubDate>
            <atom:updated>2026-09-10T10:04:15.009Z</atom:updated>
        </item>
    </channel>
</rss>