Skip to content

Repository files navigation

SwiftUI Unembed

An Xcode Source Editor Extension for the command Xcode never shipped: Unembed — remove the container view around the cursor and lift its children out one level. The inverse of Embed in VStack.

繁體中文說明在下方

// Cursor anywhere inside — Editor ▸ SwiftUI Unembed ▸ Unembed Container
VStack(spacing: 16) {
    Text("one")
    Text("two")
}

becomes

Text("one")
Text("two")

Layout

Path Purpose
Sources/UnembedCore/ All of the detection and rewriting logic. Plain Foundation, no XcodeKit
Tests/UnembedCoreTests/ Swift Testing suite, runs under swift test without opening Xcode
UnembedExtension/ The Source Editor Extension target. Only bridges XcodeKit and converts coordinates
SwiftUIUnembed/ Host app for the extension; its window is the setup instructions
Package.swift Lets the core be iterated with swift test; the Xcode target compiles the same files
Signing.xcconfig The one file you have to edit — your Apple Developer Team ID

The core deliberately avoids SwiftSyntax. The extension is an appex that Xcode loads, and carrying a copy of SwiftSyntax would noticeably slow that down. SwiftLexer only works out which characters are code and how the brackets nest, which is enough to find a trailing closure, and it correctly skips comments, string literals, #"raw"# strings, multiline strings, and \(interpolation).

Installation

There is no downloadable build. An Xcode Source Editor Extension has to embed XcodeKit.framework inside the appex, and dyld requires the loading process and the nested library to carry the same Team ID — ad-hoc signing does not work (the actual error is below). Shipping a prebuilt binary would need a Developer ID certificate and Apple notarization, which I don't have. So build it yourself; it takes a minute.

Requirements

  • macOS with Xcode installed (developed against Xcode 27; older versions likely work but are untested)
  • An Apple Developer Team ID. A free Apple ID is enough — signing in under Xcode ▸ Settings ▸ Accounts creates a personal team and an Apple Development certificate. No paid membership needed.

Steps

Put your own Team ID in Signing.xcconfig:

DEVELOPMENT_TEAM = YOUR_TEAM_ID

To find it:

security find-certificate -c "Apple Development" -p | openssl x509 -noout -subject

The OU field in the output is your Team ID. Then:

xcodebuild -project SwiftUIUnembed.xcodeproj -scheme SwiftUIUnembed -configuration Release build
  1. Move the resulting SwiftUIUnembed.app to /Applications — or anywhere permanent, since macOS only registers an app it can find again.
  2. Launch the app once. Don't skip this. macOS only vends an extension once its containing app has been launched, and you have to repeat it after every reinstall because replacing the bundle mints a new plug-in UUID.
  3. System Settings ▸ General ▸ Login Items & Extensions ▸ Xcode Source Editor, and switch SwiftUI Unembed on.
  4. Restart Xcode — ⌘Q, not just closing the window. The command lands under Editor ▸ SwiftUI Unembed ▸ Unembed Container, at the very bottom of the Editor menu, so you have to scroll. Pressing ⌘⇧/ and typing Unembed is quicker.
  5. For a keyboard shortcut, search Unembed in Xcode ▸ Settings ▸ Key Bindings.

Signing uses CODE_SIGN_IDENTITY = "Apple Development", with the Team ID read from Signing.xcconfig.

Do not switch to ad-hoc (CODE_SIGN_IDENTITY = "-"). Tested: the build succeeds and codesign --deep --strict passes, but the extension dies in dyld the moment it launches:

Library not loaded: @rpath/XcodeKit.framework/Versions/A/XcodeKit
Reason: ... not valid for use in process:
        mapping process and mapped file (non-platform) have different Team IDs

dyld requires the loading process and the nested library to have matching Team IDs. Under ad-hoc both are TeamIdentifier=not set, and dyld does not treat "neither has one" as a match. Since XcodeKit must be embedded (see troubleshooting below), the signature must carry a real Team ID. The two are coupled.

Updating an installed copy

xcodebuild -project SwiftUIUnembed.xcodeproj -scheme SwiftUIUnembed -configuration Release -derivedDataPath ./build build \
  && rm -rf /Applications/SwiftUIUnembed.app \
  && cp -R ./build/Build/Products/Release/SwiftUIUnembed.app /Applications/ \
  && rm -rf ./build \
  && open -a /Applications/SwiftUIUnembed.app

That trailing open is step 2 above — don't drop it. And rm -rf ./build isn't fastidiousness: xcodebuild runs RegisterWithLaunchServices on every build, registering the app at that path. Build to a few different locations (the default DerivedData, ./build, a temp dir) and the Xcode Source Editor list in System Settings sprouts several identically named SwiftUIUnembed rows. Nothing breaks — pluginkit only honours one — but the list gets ugly.

If it does happen, list what is registered:

/System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/LaunchServices.framework/Versions/A/Support/lsregister -dump | grep -oE "^\s*path:.*SwiftUIUnembed\.app.*"

Then run lsregister -u <path> once per unwanted path, and close and reopen the System Settings pane.

Troubleshooting: the command isn't in the menu at all

It's enabled in System Settings, Xcode has been restarted, and the bottom of the Editor menu still has no SwiftUI Unembed.

Most likely: XcodeKit isn't embedded in the bundle

Xcode checks whether the appex incorporates XcodeKit.framework and silently skips it if not — no process launched, nothing logged, no menu item created. Searching the system log turns up nothing, because Xcode never touched it.

Apple's own template defaults XcodeKit to Do Not Embed, so a project built from the template walks straight into this. This project's extension target has an Embed Frameworks phase (dstSubfolderSpec = 10) that copies XcodeKit into UnembedExtension.appex/Contents/Frameworks and re-signs it with CodeSignOnCopy.

To check:

ls /Applications/SwiftUIUnembed.app/Contents/PlugIns/UnembedExtension.appex/Contents/Frameworks/

XcodeKit.framework has to be there.

CodeSignOnCopy isn't optional. Xcode always strips Headers/ and Modules/ when embedding a framework, which invalidates Apple's original signature — codesign --deep --strict then reports a wall of file missing. Only after re-signing does it come back valid on disk:

codesign -vvv --deep --strict /Applications/SwiftUIUnembed.app

Next: dyld can't load it

/Applications/SwiftUIUnembed.app/Contents/PlugIns/UnembedExtension.appex/Contents/MacOS/UnembedExtension

A healthy bundle prints only An XPC Service cannot be run directly. — meaning dyld resolved everything and _XCExtensionMain ran. If you get Library not loaded: @rpath/XcodeKit.framework/..., XcodeKit isn't in the bundle (see above) and @loader_path/../Frameworks can't find it.

Crash reports live here:

ls -lt ~/Library/Logs/DiagnosticReports/ | grep -i unembed

Before reading one, check whether responsibleProc is actually Xcode. Running that binary yourself from a terminal also leaves a crash report, which is easy to misread as "Xcode tried to load it and failed":

python3 -c "
import json; raw=open('path/to/report.ips').read().split(chr(10),1)
b=json.loads(raw[1]); print(b.get('responsibleProc'), b.get('procLaunch'))
"

Other checks

pluginkit -mAvv -p com.apple.dt.Xcode.extension.source-editor

A leading + means the user elected to use the plug-in (- means ignore). Path should point at the /Applications copy. UUID changes after every reinstall — if it hasn't, you skipped launching the app in step 2.

Behaviour

Put the cursor anywhere inside a container; no selection needed. If the selection happens to cover a whole container including both braces, that container is removed; otherwise it's the nearest one enclosing the cursor.

Removed: capitalised callees whose trailing closure takes no parameters — VStack, HStack, ZStack, Group, ScrollView, List, Form, NavigationStack, LazyVGrid, GroupBox, and your own MyCard { … }. See ContainerFinder.knownContainers.

Not removed (the search keeps walking outwards):

Case Reason
if / switch / for / do blocks Not view containers
struct / func / var body: some View Declarations, not expressions
#Preview { } A macro, not a container
ForEach(items) { item in … } The closure binds a parameter; removing it would leave item dangling
Button("x") { action } The trailing closure is an action, not content — see deniedCallees
Section { … } header: { … } Multiple trailing closures; removing only the first orphans header:

A discarded parameter, as in GeometryReader { _ in … }, is safe and does get unembedded.

When the container carries modifiers

With a single child, the modifiers move onto that child:

VStack { Text("only") }.padding()      Text("only")
                                            .padding()

With several children there is nothing for them to attach to. Rather than dropping them silently, they are preserved as comments for you to resolve:

// unembed: VStack modifiers could not be reattached to 2 children:
// .padding()
Text("one")
Text("two")

Indentation is rebuilt from the buffer's own indentationWidth / usesTabsForIndentation, and relative indentation and blank lines between children are preserved. The promoted content stays selected afterwards, so pressing the command again peels off the next container out.

Development

swift test

Logic changes only touch Sources/UnembedCore/, and swift test runs all 28 tests in milliseconds — no Xcode restart needed to verify anything. UnembedExtension/UnembedCommand.swift only converts between XCSourceTextPosition (zero-based lines, UTF-16 columns) and character offsets; it holds no logic of its own.

License

MIT, see LICENSE.


SwiftUI Unembed(繁體中文)

Xcode Source Editor Extension,提供 Xcode 沒有的 Unembed:把游標所在的容器 view 拿掉,並把它的子 view 往外提一層。也就是 Embed in VStack 的反向操作。

// 游標在任何一行 —— Editor ▸ SwiftUI Unembed ▸ Unembed Container
VStack(spacing: 16) {
    Text("one")
    Text("two")
}

變成

Text("one")
Text("two")

專案結構

路徑 用途
Sources/UnembedCore/ 全部的判斷與改寫邏輯,純 Foundation,不依賴 XcodeKit
Tests/UnembedCoreTests/ Swift Testing 測試,swift test 可直接跑,不用開 Xcode
UnembedExtension/ Source Editor Extension target,只負責接 XcodeKit 與座標換算
SwiftUIUnembed/ 承載 extension 的 host app,畫面就是啟用步驟說明
Package.swift 讓核心邏輯能用 swift test 迭代;Xcode target 直接編譯同一批檔案
Signing.xcconfig 唯一需要你修改的檔案:填入自己的 Apple Developer Team ID

核心刻意不用 SwiftSyntax:extension 是被 Xcode 載入的 appex,多帶一份 SwiftSyntax 會明顯拖慢載入。SwiftLexer 只掃出「哪些字元是程式碼」和「括號怎麼配對」,這對找 trailing closure 已經夠用,而且能正確跳過註解、字串、#"raw"#、多行字串與 \(插值)

安裝

沒有提供可下載的安裝檔。Xcode Source Editor Extension 必須把 XcodeKit.framework 嵌進 appex,而 dyld 要求載入方與被載入的巢狀 library 具有相同的 Team ID —— ad-hoc 簽章行不通(下方有實測的錯誤訊息)。要散佈預先建置的版本就得用 Developer ID 憑證簽章並送 Apple 公證,我沒有。所以請自己 build,只要一分鐘。

需求

  • macOS,安裝 Xcode(開發時使用 Xcode 27,較舊版本應該也可以,未測)
  • 一組 Apple Developer Team ID。免費的 Apple ID 就夠:在 Xcode ▸ Settings ▸ Accounts 登入後會自動產生個人 team 與 Apple Development 憑證,不需要付費會員資格

步驟

先把 Signing.xcconfig 裡的 Team ID 改成你自己的:

DEVELOPMENT_TEAM = YOUR_TEAM_ID

找出你的 Team ID:

security find-certificate -c "Apple Development" -p | openssl x509 -noout -subject

輸出裡的 OU 欄位就是。然後:

xcodebuild -project SwiftUIUnembed.xcodeproj -scheme SwiftUIUnembed -configuration Release build
  1. 把 build 出來的 SwiftUIUnembed.app 放到 /Applications(或任何固定位置,macOS 只會註冊找得到的 app)。
  2. 開一次該 app。 這步不能省 —— macOS 要容器 app 被啟動過,才會把裡面的 extension 正式 vend 出去。每次覆蓋安裝後都要重做一次,因為 bundle 換掉會產生新的 plug-in UUID。
  3. 系統設定 ▸ 一般 ▸ 登入項目與延伸功能 ▸ Xcode Source Editor,勾選 SwiftUI Unembed
  4. 重開 Xcode(⌘Q 完全退出,不是關視窗)。指令在 Editor ▸ SwiftUI Unembed ▸ Unembed Container,位置在 Editor 選單最底部,要往下捲。用 ⌘⇧/ 搜尋 Unembed 最快。
  5. 想要快捷鍵的話,Xcode 設定 ▸ Key Bindings 搜尋 Unembed 自行綁定。

簽章用 CODE_SIGN_IDENTITY = "Apple Development",Team ID 從 Signing.xcconfig 讀取。

不能改成 ad-hoc(CODE_SIGN_IDENTITY = "-")。 實測過:build 會成功,codesign --deep --strict 也會過,但 extension 一啟動就死在 dyld:

Library not loaded: @rpath/XcodeKit.framework/Versions/A/XcodeKit
Reason: ... not valid for use in process:
        mapping process and mapped file (non-platform) have different Team IDs

dyld 要求載入方與被載入的巢狀 library 具有相同的 Team ID,而 ad-hoc 兩邊都是 TeamIdentifier=not set —— dyld 不把「兩邊都沒有」當成相符。既然 XcodeKit 一定得嵌進 bundle(見下方疑難排解),簽章就一定得帶真實 Team ID。這兩件事是綁在一起的。

更新已安裝的版本

xcodebuild -project SwiftUIUnembed.xcodeproj -scheme SwiftUIUnembed -configuration Release -derivedDataPath ./build build \
  && rm -rf /Applications/SwiftUIUnembed.app \
  && cp -R ./build/Build/Products/Release/SwiftUIUnembed.app /Applications/ \
  && rm -rf ./build \
  && open -a /Applications/SwiftUIUnembed.app

最後那個 open 就是上面的第 2 步,別漏掉。而 rm -rf ./build 不是潔癖。xcodebuild 每次結束都會跑 RegisterWithLaunchServices,把該路徑的 app 註冊給 LaunchServices;build 到幾個不同位置(預設 DerivedData、./build、臨時目錄……),系統設定的 Xcode Source Editor 清單就會冒出幾個同名的 SwiftUIUnembed。功能不會壞(pluginkit 只會認一份),但清單很難看。

真的跑出多份時,先列出註冊了哪些路徑:

/System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/LaunchServices.framework/Versions/A/Support/lsregister -dump | grep -oE "^\s*path:.*SwiftUIUnembed\.app.*"

再對不要的路徑各跑一次 lsregister -u <路徑>,然後把系統設定面板關掉重開。

疑難排解:選單裡完全找不到指令

系統設定裡已啟用、Xcode 也重開了,但 Editor 選單最底部就是沒有 SwiftUI Unembed。

最常見的原因:XcodeKit 沒有嵌進 bundle

Xcode 會檢查 appex 有沒有把 XcodeKit.framework 包進 bundle,沒有就靜默跳過 —— 不啟動 process、不寫任何日誌、不建選單項目。查日誌會一無所獲,因為 Xcode 根本沒去碰它。

Apple 的官方範本預設把 XcodeKit 設成 Do Not Embed,所以照範本建的專案一開始就中這個坑。本專案的 extension target 有一個 Embed Frameworks 階段(dstSubfolderSpec = 10)把 XcodeKit 複製進 UnembedExtension.appex/Contents/Frameworks,並以 CodeSignOnCopy 重簽。

確認方式:

ls /Applications/SwiftUIUnembed.app/Contents/PlugIns/UnembedExtension.appex/Contents/Frameworks/

必須看到 XcodeKit.framework

CodeSignOnCopy 不能省。Xcode 嵌入 framework 時一定會剝除 Headers/Modules/,Apple 原本的簽章因此失效,codesign --deep --strict 會噴一整排 file missing。重簽之後才會是 valid on disk

codesign -vvv --deep --strict /Applications/SwiftUIUnembed.app

其次:dyld 載不起來

/Applications/SwiftUIUnembed.app/Contents/PlugIns/UnembedExtension.appex/Contents/MacOS/UnembedExtension

正常只會印 An XPC Service cannot be run directly. —— 代表 dyld 全部解析成功、_XCExtensionMain 有跑起來。若印出 Library not loaded: @rpath/XcodeKit.framework/...,就是 XcodeKit 不在 bundle 裡(同上),@loader_path/../Frameworks 找不到它。

崩潰報告在這裡:

ls -lt ~/Library/Logs/DiagnosticReports/ | grep -i unembed

看報告前先確認 responsibleProc 是不是 Xcode。自己在終端機直接執行那個 binary 也會留下崩潰報告,很容易誤判成「Xcode 嘗試載入失敗」:

python3 -c "
import json; raw=open('報告路徑.ips').read().split(chr(10),1)
b=json.loads(raw[1]); print(b.get('responsibleProc'), b.get('procLaunch'))
"

其他檢查

pluginkit -mAvv -p com.apple.dt.Xcode.extension.source-editor

開頭的 + 代表使用者已啟用(- 是忽略)。Path 要指向 /Applications 那份。覆蓋安裝後 UUID 會變,若沒變就是第 2 步的「開一次 app」漏了。

行為規則

游標放在容器內任一處即可,不必選取。若選取範圍剛好完整涵蓋一個容器(含頭尾大括號),則拆掉「那個」容器;否則拆掉游標外層最近的一個。

會拆的:大寫開頭、trailing closure 沒有參數的呼叫 —— VStackHStackZStackGroupScrollViewListFormNavigationStackLazyVGridGroupBox 等,以及你自己寫的 MyCard { … }。清單見 ContainerFinder.knownContainers

不會拆的(會自動往外層繼續找):

情況 原因
if / switch / for / do 區塊 不是 view 容器
struct / func / var body: some View 是宣告,不是運算式
#Preview { } macro,不是容器
ForEach(items) { item in … } closure 有參數,拆掉後 item 會失去綁定
Button("x") { action } trailing closure 是動作不是內容,見 deniedCallees
Section { … } header: { … } 多個 trailing closure,只拆第一個會留下孤兒 header:

GeometryReader { _ in … } 這種參數被丟棄的 closure 是安全的,會照拆。

容器身上掛了 modifier 時

只有一個子 view 時,modifier 直接接到那個子 view 上:

VStack { Text("only") }.padding()      Text("only")
                                            .padding()

有多個子 view 時,modifier 無處可掛。與其默默刪掉,會把它保留成註解讓你自己決定:

// unembed: VStack modifiers could not be reattached to 2 children:
// .padding()
Text("one")
Text("two")

縮排會依照 buffer 的 indentationWidth / usesTabsForIndentation 設定重排,子 view 之間的相對縮排與空行都保留。拆完之後被提上來的內容會維持選取,方便連續按第二次往外再拆一層。

開發

swift test

改邏輯只要動 Sources/UnembedCore/swift test 幾毫秒就跑完 28 個測試,不需要重啟 Xcode 驗證。UnembedExtension/UnembedCommand.swift 只做 XCSourceTextPosition(0-based 行號 + UTF-16 欄位)與字元位移之間的換算,不含判斷邏輯。

授權

MIT,見 LICENSE

About

Xcode Source Editor Extension that removes the SwiftUI container around the cursor — the inverse of Embed in VStack

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages