使用 Doom Emacs 构建 iOS 应用
Building iOS Apps with Doom Emacs

原始链接: https://wassimans.com/blog/building-ios-apps-with-doom-emacs/

作者成功地将标准的 Xcode 开发流程替换为定制的 Doom Emacs 设置。通过利用苹果的命令行工具(特别是 `xcodebuild`、`xcrun simctl`、`sourcekit-lsp` 和 `xcode-build-server`),作者在自己偏好的文本编辑器中创建了一个无缝的开发循环。 此工作流程的关键点包括: * **自动化**:通过专用的 `ios.el` 配置,可以使用自定义快捷键同时在多个模拟器上进行构建、安装和启动应用程序。 * **集成**:`xcode-build-server` 启用了 LSP 支持,而 `apheleia` 则负责 Swift 代码格式化。通过日志“白名单”过滤功能,可以剔除苹果内部系统的冗余信息,仅显示相关的开发者输出。 * **脚手架**:使用 `xcodegen` 可以通过 YAML 文件创建项目,从而无需使用 Xcode 的项目编辑器。 * **一致性**:作者在多种语言(Rust、Elixir、Swift 等)中保持了统一的工作流程,将 iOS 开发整合到了现有的 Emacs 生态系统中。 虽然作者在处理签名、资源管理和性能分析等不常用的特殊任务时仍会使用 Xcode,但日常的“编写-构建-测试”循环已完全在 Emacs 中完成。作者总结认为,尽管此设置对于现有的 Emacs 高级用户来说非常理想,但 Xcode 对于其他人而言依然是一个完全可行的工具。

Hacker News 最新 | 过往 | 评论 | 提问 | 展示 | 招聘 | 提交 登录 使用 Doom Emacs 构建 iOS 应用 (wassimans.com) 8 分,由 wassimans 发布于 1 小时前 | 隐藏 | 过往 | 收藏 | 讨论 | 帮助 指南 | 常见问题 | 列表 | API | 安全 | 法律 | 申请 YC | 联系 搜索:
相关文章

原文

I shipped SPEEM, my first iOS app, from Doom Emacs. Not from Xcode.

I don’t mean I just edited a few files in Emacs and switched back when it was time to build. I mean the whole loop: write Swift, build, boot a simulator, install the app, launch it, stream logs, restart LSP, scaffold new projects. All from inside Emacs, all driven by SPC i keybindings I wrote myself.

This post is about how, and why it’s even possible.

TL;DR

Apple ships a small army of command-line tools, xcodebuild, xcrun simctl, xcrun swift-format, sourcekit-lsp, and most iOS developers never touch them directly. Xcode is just a fancy wrapper around them (of course with many other utilities built into it). If you’re willing to glue those tools together yourself, you can build a real iOS workflow in any editor that lets you run shell commands and read process output. Doom Emacs happens to be very, very good at that (or any Emacs distro for that matter really).

The result, for me, is a ~1000-line modules/ios.el that gives me an SPC i b to build, SPC i s to install and launch on selected simulators, SPC i l to stream filtered logs, SPC i n to scaffold a new SwiftUI project, and a few more things I’ll walk through below.

Why I did this

I live in Emacs. I’ve been using Doom Emacs daily for 4 years: Rust, Elixir, Kotlin/Android, web, org-mode, Magit, all of it. When I came back to iOS development for SPEEM, I tried Xcode for two weeks and it felt like wearing someone else’s shoes. Not bad shoes. Just not mine.

I also have a multi-language config. I have a modules/rust.el, a modules/elixir.el, a modules/kotlin-android.el, a modules/web.el, and so on. Each language has the same shape: a major mode, LSP, keybindings for build/run/test. iOS being the one exception felt wrong.

So I sat down and wrote modules/ios.el. This is what’s in it.

What Apple actually gives you on the command line

Before any Emacs Lisp, it helps to know what’s even there. When you xcode-select -p you get the active developer directory, which contains all the tools Xcode uses internally:

$ xcode-select -p
/Applications/Xcode.app/Contents/Developer

Inside that, the tools that matter for building and running an iOS app from a terminal are:

  • xcodebuild, compiles your .xcodeproj or .xcworkspace. It accepts a scheme, an SDK, a destination, and a list of actions like build, clean, test. This is what Xcode itself calls when you press Run.
  • xcrun simctl, controls the iOS simulators. Boot, install, uninstall, launch, terminate, take screenshots, override the status bar. Anything you can do clicking around in the Simulator app, you can do with simctl.
  • xcrun swift-format, Apple’s official Swift formatter. Works on stdin/stdout.
  • sourcekit-lsp, Apple’s language server for Swift. Ships with Xcode at Toolchains/XcodeDefault.xctoolchain/usr/bin/sourcekit-lsp. This is what gives me completion, jump-to-definition, errors, refactors.
  • xcode-build-server, not from Apple, but essential. It generates a buildServer.json that tells sourcekit-lsp how the project is actually configured (build flags, modules, etc). Without it, LSP works but it’s blind to half your project. brew install xcode-build-server.
  • xcodegen, also not from Apple. Generates an .xcodeproj from a small YAML file. I use it to scaffold new projects so I don’t have to ever open Xcode’s project editor.

Once you know those exist, the rest is just orchestration.

The structure of my Doom config

My Doom config is split into modules. The top-level config.el is tiny:

;;; config.el -*- lexical-binding: t; -*-

(setq doom-font              (font-spec :family "Fira Code" :size 13 :weight 'semi-light)
      doom-variable-pitch-font (font-spec :family "Fira Sans" :size 14)
      doom-theme             'doom-monokai-pro
      display-line-numbers-type t
      org-directory          "~/org/")

(load! "modules/core")
(load! "modules/lsp")
(load! "modules/rust")
(load! "modules/elixir")
(load! "modules/ios")
(load! "modules/kotlin-android")
(load! "modules/web")
(load! "modules/slint")
(load! "modules/projectile")
(load! "modules/org")
(load! "modules/tools")
(load! "modules/keybindings")   ; all map! calls live here, loaded last

The iOS-specific bits live in modules/ios.el, and the keybindings for them in modules/keybindings.el. I keep keybindings separate from feature code so that everything map! is in one place, easier to grep, easier to reason about.

On the Doom-modules side, init.el enables the standard (swift +lsp) language module:

:lang
(swift +lsp)             ; who asked for emoji variables?

That gives me swift-mode and LSP plumbing for free. Everything below is built on top of that.

Pointing LSP at the right sourcekit-lsp

There’s a subtlety here. There are usually two sourcekit-lsp binaries on a Mac: the one in /Library/Developer/CommandLineTools/... and the one inside the active Xcode. They’re not always the same version, and using the wrong one can break diagnostics in confusing ways. I want the one that matches my active Xcode.

(let* ((dev   (string-trim (shell-command-to-string "xcode-select -p")))
       (sklsp (expand-file-name
               "Toolchains/XcodeDefault.xctoolchain/usr/bin/sourcekit-lsp" dev)))
  (setenv "DEVELOPER_DIR" dev)
  (setq lsp-sourcekit-executable sklsp
        lsp-sourcekit-extra-args  nil))

I set DEVELOPER_DIR so any subprocess (xcodebuild, xcrun ...) inherits the same Xcode. Then I pin lsp-sourcekit-executable to that exact path.

For swift-mode itself the setup is small, turn on lsp-deferred, disable inlay hints (I find them noisy), set indentation to 4 spaces:

(use-package! swift-mode
  :mode "\\.swift\\'"
  :hook ((swift-mode . lsp-deferred)
         (swift-mode . (lambda () (setq-local lsp-inlay-hint-enable nil))))
  :config
  (setq swift-mode:basic-offset 4
        indent-tabs-mode         nil))

Format-on-save is handled by apheleia, calling swift-format via xcrun:

(after! apheleia
  (setf (alist-get 'swift-format apheleia-formatters)
        '("xcrun" "swift-format" "format" "--assume-filename" filepath "-"))
  (setf (alist-get 'swift-mode apheleia-mode-alist) '(swift-format)))
(add-hook 'swift-mode-hook #'apheleia-mode)

That’s it for the editor surface. Open a .swift file, get completion, jump-to-def, errors, format on save. Nothing exotic so far.

Auto-generating buildServer.json

This is the part that surprises people. Out of the box, sourcekit-lsp doesn’t know what your project’s build flags are. It guesses. The fix is xcode-build-server: it reads your .xcodeproj, asks xcodebuild how the project actually compiles, and writes a buildServer.json that LSP can read.

I don’t want to remember to run it. So I hook it into swift-mode: the first time I open a Swift file in a project without a buildServer.json, generate one.

(defun wassim/+swift-ensure-build-server-json ()
  "Generate buildServer.json for the current Xcode project if missing."
  (let* ((root      (wassim/swift-xcode-root))
         (bsp-file  (expand-file-name "buildServer.json" root))
         (xcodeproj (car (directory-files root t "\\.xcodeproj\\'"))))
    (when (and xcodeproj (not (file-exists-p bsp-file)))
      (let* ((proj-name (file-name-base xcodeproj))
             (cmd       (format
                         "cd %s && xcode-build-server config -project %s.xcodeproj -scheme %s"
                         (shell-quote-argument root)
                         (shell-quote-argument proj-name)
                         (shell-quote-argument proj-name))))
        (when (executable-find "xcode-build-server")
          (message "Generating buildServer.json for sourcekit-lsp...")
          (shell-command cmd)
          (message "✅ buildServer.json generated. Restart LSP with SPC l R"))))))

(add-hook 'swift-mode-hook #'wassim/+swift-ensure-build-server-json)

The companion command for when something changes (new file added, scheme renamed) is bound to SPC i g:

(defun wassim/swift-regenerate-build-server ()
  "Manually regenerate buildServer.json for the current Xcode project."
  (interactive)
  ;; ... deletes the existing file and re-runs xcode-build-server ...
  )

Finding the project root

This one is more annoying than it sounds. The “root” of an iOS project is the directory containing the .xcworkspace or .xcodeproj. But if your cursor is somewhere deep inside App/Features/Auth/Views/LoginView.swift, you have to climb up to find it. Worse, if you happen to be editing a file inside the .xcodeproj bundle (which happens, Xcode stores some files there), you need to climb out of the bundle first.

I wrote a small set of helpers for this:

(defun wassim/xcode--unbundle (dir)
  "Climb out if DIR is inside a .xcodeproj/.xcworkspace bundle."
  (let ((d (file-name-as-directory (expand-file-name dir))))
    (while (string-match-p "\\.xcodeproj/\\|\\.xcworkspace/" d)
      (setq d (file-name-directory (directory-file-name d))))
    d))

(defun wassim/swift-xcode-root ()
  "Project root containing a top-level .xcworkspace or .xcodeproj."
  (let* ((start (wassim/xcode--unbundle
                 (if buffer-file-name
                     (file-name-directory buffer-file-name)
                   default-directory)))
         (root  (or (locate-dominating-file
                     start
                     (lambda (dir)
                       (or (wassim/xcode--find-top-workspace dir)
                           (wassim/xcode--find-project dir))))
                    (vc-root-dir)
                    (ignore-errors (projectile-project-root))
                    default-directory)))
    (file-name-as-directory (expand-file-name root))))

Everything that builds, runs, installs, or talks to the simulator calls wassim/swift-xcode-root first. I also force default-directory to that root for any Swift buffer, so M-x compile and friends always behave correctly:

(add-hook 'swift-mode-hook              #'wassim/swift-set-project-root-default-directory)
(add-hook 'find-file-hook               #'wassim/swift-set-project-root-default-directory)
(add-hook 'after-change-major-mode-hook #'wassim/swift-set-project-root-default-directory)

Building

The build command is just xcodebuild with the right arguments. There are two non-obvious things to get right:

  1. Pick the .xcworkspace if there is one (CocoaPods / SPM projects), otherwise the .xcodeproj.
  2. Pick a scheme. If the project has shared schemes (in xcshareddata/xcschemes), use them. Otherwise fall back to the user’s schemes or the target name.

I have a helper wassim/xcode--container+selector that returns (kind container selector-flag selector-value) so the rest of the code doesn’t have to care:

(defun wassim/xcode--container+selector ()
  "Return (kind container selector sel-arg), using a scheme when available."
  (let* ((root   (wassim/swift-xcode-root))
         (ws     (wassim/xcode--find-top-workspace root))
         (proj   (wassim/xcode--find-project root))
         (kind   (if ws "workspace" "project"))
         (cont   (or ws proj (user-error "No top-level .xcworkspace/.xcodeproj in %s" root)))
         (scheme (wassim/xcode--prefer-scheme cont kind)))
    (if scheme
        (list kind cont "-scheme" scheme)
      (list "project" (or proj cont) "-target" (file-name-base cont)))))

The build itself is SPC i b. It resolves Swift Package dependencies first, then builds, then (on success) restarts sourcekit-lsp so the diagnostics refresh immediately:

(defun wassim/ios-build (&optional clean)
  "Resolve packages then build. C-u to clean first.
Restarts sourcekit-lsp on success so diagnostics refresh immediately."
  (interactive "P")
  (cl-destructuring-bind (kind cont sel selarg) (wassim/xcode--container+selector)
    (let* ((root      (wassim/swift-xcode-root))
           (dest      (shell-quote-argument (wassim/xcode--dest)))
           (clean-cmd (when clean
                        (format "xcodebuild -%s %s %s %s -configuration Debug -sdk iphonesimulator -destination %s clean"
                                kind (shell-quote-argument cont)
                                sel  (shell-quote-argument selarg)
                                dest)))
           (resolve   "xcodebuild -resolvePackageDependencies")
           (build     (format
                       "xcodebuild -%s %s %s %s -configuration Debug -sdk iphonesimulator -destination %s COMPILER_INDEX_STORE_ENABLE=YES build"
                       kind (shell-quote-argument cont)
                       sel  (shell-quote-argument selarg)
                       dest))
           (script    (string-join (delq nil (list (format "cd %s" (shell-quote-argument root))
                                                   clean-cmd resolve build))
                                   " && ")))
      (let ((buf (compile script)))
        ;; on success: restart LSP workspaces so diagnostics pick up the new build
        ;; ...
        ))))

COMPILER_INDEX_STORE_ENABLE=YES is important: it tells the Swift compiler to write a clang-style index store to DerivedData, which I then point sourcekit-lsp’s embedded clangd at. That gives me proper cross-file “find references” instead of textual guesses.

(defun wassim/+swift-point-clangd-to-index ()
  "Point SourceKit's embedded clangd at Xcode's Index Store (buffer-local)."
  (let* ((store (wassim/+swift-index-store-path))
         (arg   (concat "--index-store-path=" store)))
    (setq-local lsp-sourcekit-extra-args
                (append lsp-sourcekit-extra-args
                        (when (file-directory-p store)
                          (list "-Xclangd" arg))))))
(add-hook 'swift-mode-hook #'wassim/+swift-point-clangd-to-index)

Output goes into a regular *compilation* buffer. xcodebuild errors are clickable. SPC c x jumps to the next error, just like in any other language.

Picking simulators

I run my app on several simulators at once. SPEEM ships in both English and Arabic, on iPhone and iPad. So my “run” command isn’t “send to one simulator”, it’s “pick from this list, possibly several, and deploy to all of them”.

(defvar wassim/ios-simulators
  '("Wassim iPad AR (26.2)"
    "Wassim iPad EN (26.2)"
    "Wassim AR (26.2)"
    "Wassim EN (26.2)"
    "Tamim iPad AR (26.2)"
    "Tamim iPad EN (26.2)"
    "Tamim AR (26.2)"
    "Tamim EN (26.2)")
  "Named simulators available for multi-sim run commands.")

(defun wassim/ios--pick-simulators ()
  "Prompt for one or more simulators using `completing-read-multiple'."
  (let ((selected (completing-read-multiple
                   "Simulators: "
                   wassim/ios-simulators
                   nil t)))
    (unless selected (user-error "No simulators selected"))
    selected))

With Vertico (Doom’s default completion), completing-read-multiple is a real pleasure: I type “ipad”, I get the iPad ones, I press TAB to select more than one. That’s the picker.

Booting a simulator by name resolves its UDID via simctl list devices -j (JSON output), then simctl boot plus simctl bootstatus -b to wait until it’s actually ready:

(defun wassim/ios--boot-sim (name)
  "Boot simulator NAME if not already booted. Returns its UDID."
  (let ((udid (wassim/ios--sim-udid name)))
    (unless udid (user-error "Simulator %s not found" name))
    (start-process "sim" nil "open" "-a" "Simulator")
    (call-process "bash" nil nil nil "-lc"
                  (format "xcrun simctl boot %s 2>/dev/null; xcrun simctl bootstatus %s -b 2>/dev/null || true"
                          (shell-quote-argument udid)
                          (shell-quote-argument udid)))
    (wassim/ios--apply-clean-statusbar udid)
    udid))

The status-bar override on the last line is a small luxury: every booted simulator gets the App Store screenshot look applied automatically, 9:41, full bars, 100% battery, no carrier name. That way any screenshot I take during development is already App Store-ready. There’s a toggle, wassim/ios-clean-statusbar-on-boot, and an interactive command wassim/ios-statusbar-clear (bound to SPC i T) to undo it.

Running

SPC i s is “build + install + launch on all selected simulators”. The flow is:

  1. Pick simulators.
  2. Boot each one in parallel.
  3. Build for iphonesimulator, destined to the first sim’s UDID.
  4. Find the resulting .app in DerivedData (newest one wins).
  5. Read its bundle ID from Info.plist via PlistBuddy.
  6. For each booted sim: terminate the old instance, install the new .app, launch it.
  7. Start log streams (more on this below).

The whole thing runs in a single compile invocation chained with &&, so any failure halts the chain and shows up in the compilation buffer:

(defun wassim/ios-run ()
  "Build for simulator then install & launch on selected simulators."
  (interactive)
  (let ((sims (wassim/ios--pick-simulators)))
    (cl-destructuring-bind (kind cont sel selarg) (wassim/xcode--container+selector)
      (let* ((root    (wassim/swift-xcode-root))
             (udids   (mapcar #'wassim/ios--boot-sim sims))
             (build   (format
                       "xcodebuild -%s %s %s %s -configuration Debug -sdk iphonesimulator -destination %s COMPILER_INDEX_STORE_ENABLE=YES build"
                       kind (shell-quote-argument cont)
                       sel  (shell-quote-argument selarg)
                       (shell-quote-argument (wassim/xcode--dest (car udids)))))
             (deploy  (cl-loop for udid in udids
                               for name in sims
                               append
                               (list
                                (format "echo '── Installing on %s ──'" name)
                                (format "xcrun simctl terminate %s \"$BID\" >/dev/null 2>&1 || true"
                                        (shell-quote-argument udid))
                                (format "xcrun simctl install %s \"$APP\"" (shell-quote-argument udid))))))
        (let ((buf (compile
                    (mapconcat
                     #'identity
                     (append
                      (list (format "cd %s" (shell-quote-argument root))
                            "xcodebuild -resolvePackageDependencies"
                            build
                            "APP=$(ls -dt ~/Library/Developer/Xcode/DerivedData/*/Build/Products/Debug-iphonesimulator/*.app 2>/dev/null | head -1)"
                            "[ -n \"$APP\" ] || { echo 'No .app found in DerivedData'; exit 1; }"
                            "BID=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' \"$APP/Info.plist\")")
                      deploy)
                     " && "))))
          (wassim/ios-logs--hook-after-compile buf sims udids))))))

There’s a sibling, SPC i S, that does the same but uninstalls first, useful when you want to wipe app state (Core Data, UserDefaults, etc.) and start fresh.

Auto-reload

For tight UI iteration I have SPC i r, which uses fd and entr to rebuild and redeploy whenever a .swift file changes:

(defun wassim/ios-autoreload-start ()
  "Watch Swift files and auto build/install/launch on selected simulators."
  (interactive)
  (unless (executable-find "entr") (user-error "Missing `entr` (brew install entr)"))
  (unless (executable-find "fd")   (user-error "Missing `fd` (brew install fd)"))
  ;; ... pick sims, boot them, build/install/launch ...
  ;; ... then: fd -t f -e swift | entr -r sh -c '<full build+deploy command>'
  )

It’s a poor man’s hot-reload, but for iterating on layouts and small SwiftUI tweaks it works really well. SPC i R (capital) stops it.

Logs

This was the part where I really started to feel the “Emacs is a buffer manager” thing pay off. Each simulator gets its own log buffer, *iOS Logs: <name>*, and I can have several open at once side by side. Logs come from two sources:

  1. stdout/stderr of the app process, via simctl launch --console-pty. That gets me every print() call.
  2. The system log stream, via simctl spawn UDID log stream --predicate 'process == "MyApp"'. That gets me errors, warnings, and os_log output.

Raw simulator logs are extremely noisy, most of it is internal Apple chatter you don’t care about. So I run everything through a whitelist filter:

(defun wassim/ios-logs--keep-p (line)
  "Return non-nil if LINE should be shown.
Whitelist approach: only show app prints, errors, warnings, and CloudKit issues."
  (let ((lc (downcase line)))
    (or
     ;; App print() statements (emoji markers or [PRINT] tag)
     (string-match-p "✅\\|❌\\|⚠️\\|📡\\|📜\\|☁️\\|📝\\|🆕\\|📊\\|🔍\\|📣\\|\\[PRINT\\]" line)
     ;; Errors and faults
     (string-match-p "\\bfault\\b\\|\\bfatal\\b\\|\\bpanic\\b\\|\\bcrash\\b\\|\\bassertion failed\\b\\|\\bterminat\\w*\\b" lc)
     (string-match-p "\\berror\\b\\|\\bfailed\\b\\|\\bexception\\b" lc)
     ;; Warnings
     (string-match-p "\\bwarn\\b" lc)
     ;; CloudKit problems specifically
     (and (string-match-p "cloudkit" lc)
          (string-match-p "\\bbad container\\b\\|\\berror\\b\\|\\bfailed\\b\\|\\bretry\\b\\|\\bcouldn'?t\\b" lc)))))

The emoji prefixes come from a convention I keep in my Swift code: print("✅ user signed in"), print("❌ failed to sync: \(err)"), print("☁️ CloudKit sync started"). The filter recognises them as “this came from my code, definitely keep it”. Errors and warnings always come through. Apple’s nw_connection, BackBoardServices, RunningBoard noise gets dropped.

Each line is then colored by severity:

(defun wassim/ios-logs--face (lvl)
  (pcase lvl
    ('error 'compilation-error)
    ('warn  'compilation-warning)
    (_      'success)))

SPC i l starts the streams (against whichever simulator is currently booted), SPC i L stops them, SPC i c clears the buffers.

Scaffolding a new project

SPC i n runs wassim/ios-new-project, which prompts for a name, bundle ID prefix, deployment target, and parent directory, then writes a minimal SwiftUI app and a project.yml for XcodeGen:

;; ── project.yml (XcodeGen spec) ──
(write-region
 (format "name: %s
options:
  bundleIdPrefix: %s
  deploymentTarget:
    iOS: \"%s\"
targets:
  %s:
    type: application
    platform: iOS
    sources:
      - %s
    info:
      path: %s/Info.plist
    settings:
      INFOPLIST_KEY_UIApplicationSceneManifest_Generation: YES
      INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES
      INFOPLIST_KEY_UILaunchScreen_Generation: YES
      INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad: \"...\"
      INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone: \"...\"
      SWIFT_EMIT_LOC_STRINGS: YES
" name prefix target name name name)
 nil (expand-file-name "project.yml" root))

Then it shells out to xcodegen generate to produce the .xcodeproj, runs xcode-build-server config to write the initial buildServer.json, and opens the freshly-created ContentView.swift. By the time I see the file, LSP is already alive and SPC i b builds.

I never have to open Xcode’s project editor.

The keybindings, all in one place

This is the whole iOS prefix, lifted verbatim from modules/keybindings.el:

(map! :leader
      (:prefix ("i" . "iOS build/run")
       :desc "Build (resolve+build, restart LSP)"     "b" #'wassim/ios-build
       :desc "Clean+Build"                            "B" (cmd! (let ((current-prefix-arg '(4)))
                                                                  (call-interactively #'wassim/ios-build)))
       :desc "Clean + wipe index"                     "k" #'wassim/ios-clean
       :desc "Run (build+install+launch)"             "s" #'wassim/ios-run
       :desc "Run FRESH (wipe state)"                 "S" #'wassim/ios-run-fresh
       :desc "Auto-reload start"                      "r" #'wassim/ios-autoreload-start
       :desc "Auto-reload stop"                       "R" #'wassim/ios-autoreload-stop
       :desc "Logs (Xcode-like)"                      "l" #'wassim/ios-logs-start
       :desc "Logs stop"                              "L" #'wassim/ios-logs-stop
       :desc "Logs clear"                             "c" #'wassim/ios-logs-clear
       :desc "Status bar: restore live indicators"    "T" #'wassim/ios-statusbar-clear
       :desc "Regenerate buildServer.json"            "g" #'wassim/swift-regenerate-build-server
       :desc "New project (xcodegen)"                 "n" #'wassim/ios-new-project))

That’s the contract. I learned 13 keystrokes and they cover my entire day.

My daily loop

A normal day looks like this. I open a project, SPC SPC (Projectile), pick one. A .swift file opens, lsp-deferred kicks in, buildServer.json is generated if missing. I write some code, apheleia formats on save. When I want to see it run, SPC i s, pick “Tamim EN (26.2)”, wait for the build, the simulator opens, the app launches, the log buffer fills with my print() lines, all colored. If I want to iterate fast, SPC i r and just save the file repeatedly. If something looks wrong, SPC i k to wipe and rebuild.

I almost never touch Xcode.

What still needs Xcode (being honest)

Xcode hasn’t gone away on my machine. There are a few things I still open it for:

  • Provisioning, signing, and capabilities. The signing UI is fine. I haven’t tried to replace it because the pain isn’t worth the gain. Once a year I tap through it for a new device or capability and move on.
  • Asset catalogs. I edit Assets.xcassets from Xcode when I’m doing serious work on app icons or color sets. For trivial changes I edit the JSON directly.
  • Storyboards and .xib files. I avoid these by using SwiftUI everywhere I can. When I have to touch one, it’s Xcode.
  • Instruments. Profiling, CPU, memory, leaks, runs from Xcode. No good Emacs story here, and I’m fine with that.
  • App Store Connect uploads. I use xcodebuild -exportArchive for parts of it, but the actual upload still goes through xcrun altool or Xcode. Same for screenshot uploads; simctl gives me the files, but App Store Connect is where they go.

Most of those are small, infrequent tasks. None of them are in my daily loop.

Would I recommend this?

If you’re already an Emacs user and you’ve been holding off on iOS because Xcode is “the only way”, no, it isn’t, and it never really was. Apple’s command-line tools are good. You can put your editor around them.

If you’re not already an Emacs user, this is not a reason to become one. Xcode is genuinely fine. Pick the tool that fits your hands.

I’m sharing this mostly because I spent weeks figuring out the small frictions, which sourcekit-lsp to pick, how to feed it buildServer.json, how to find the .app after a build, how to filter the log noise, and I would have loved to read a post like this when I started. Now there is one.

If you try this and get stuck, my email is on the about page. I’ll do my best to help.

联系我们 contact @ memedata.com