본문 바로가기
C.W.K.
Lesson 02 of 06 · published

맥 앱은 어디서 시작하고, 어떻게 끝나나

~16 min · appkit-swiftui, lifecycle, nsapplication, open-urls, termination

Level 0번들 열어본 사람
0 XP0/84 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"앱이 이미 켜져 있을 땐 보여주기가 매번 됐어. 꺼진 상태에서 켜질 땐 조용히 아무것도 안 했고."

진입점은 두 가지

SwiftUI 앱은 @main struct … : App에서 시작하고, AppKit 콜백이 필요하면 @NSApplicationDelegateAdaptor로 델리게이트를 붙여. AppKit이 주인인 앱은 @main 타입의 static func main()에서 시작해. 거기서 NSApplication.shared를 만들고, 델리게이트를 달고, 활성화 정책을 정하고, run()을 불러. 가족 Mac 앱 몇 개는 실행 순서가 눈에 보인다는 이유로 두 번째 방식을 써.

활성화 정책은 앱이 자기를 어떻게 드러내는지 정해. 선택지는 셋이야. .regular는 Dock 아이콘과 메뉴 막대가 있고 맨 앞으로 올 수 있어. .accessory는 Dock 아이콘 없이 창만 띄울 수 있는, 메뉴 막대 유틸리티 형태야. 나머지 하나는 .prohibited야. Info.plist에 LSUIElement를 넣으면 코드가 돌기도 전, 실행 첫 순간부터 accessory처럼 동작해.

실행 과정엔 네가 만들지 않은 경합이 있어

파일 작업대 앱이 다른 프로그램에서 오는 "이 파일 보여줘" 요청을 application(_:open:)으로 받아. 앱이 켜져 있을 땐 완벽했는데, 꺼진 상태에서 켜질 땐 아무것도 안 했어. 서로 상관없는 경합 두 개가 얽혀 있어서, 하나만 고치면 버그가 여전히 남았어.

  1. 열기 이벤트가 applicationDidFinishLaunching이 창 컨트롤러를 만들기 전에 올 수 있어. 그러면 아직 없는 컨트롤러에 대고 한 옵셔널 체이닝 호출은 아무 일도 안 해.
  2. 그 뒤에 끝나는 비동기 세션 복원이, 방금 보여준 위치를 복원된 위치로 덮어써.

고친 방법은 이래. 들어오는 URL은 전부 대기열에 넣어두고, 컨트롤러가 있고 복원도 끝났다는 두 조건이 다 맞을 때만 꺼내서 처리해. 그리고 복원은 결과가 비었을 때도 에러가 났을 때도 끝났다고 알려야 해. 안 그러면 요청이 영원히 기다려. 꺼진 상태에서 켜질 때랑 켜져 있을 때를 따로 테스트해. 켜져 있을 때 통과한 건 실행 순서에 대해 아무것도 증명 못 해.

세션을 안 잃고 끝내기

네이티브 창 탭을 쓰는 터미널이 창이 닫힐 때마다 탭 배치를 저장했어. 종료할 때 보면, applicationShouldTerminate는 창이 전부 열려 있는 상태에서 불려. 그 뒤로 창이 하나씩 닫히면서 그때마다 창이 하나씩 줄어든 스냅숏이 저장됐고, 결국 마지막 저장은 빈 세션을 기록했어. 고친 방법은 applicationShouldTerminate에서 스냅숏을 찍고, terminating 플래그를 세우고, 그 뒤의 창별 저장은 아무것도 안 하게 하는 거야.

함대 관리 도구라면 알아야 할 종료 관련 사실이 하나 더 있어. 문서 앱은 죽이지 말고 정중하게 종료해야 해(Apple Event나 종료 메뉴로). 저장 안 된 문서를 챙기는 applicationShouldTerminate는 SIGTERM에선 절대 안 불려.

Code

AppKit이 주인인 앱: 명시적 main, 대기열에 넣은 열기 요청, 종료 스냅숏 한 번·swift
import AppKit
import SwiftUI

@main
@MainActor
final class SparkAppDelegate: NSObject, NSApplicationDelegate {
    private var windowController: NSWindowController?
    private var pendingURLs: [URL] = []
    private var restoreFinished = false
    private var terminating = false

    static func main() {
        let app = NSApplication.shared
        let delegate = SparkAppDelegate()
        app.delegate = delegate
        app.setActivationPolicy(.regular)
        app.run()
    }

    func applicationDidFinishLaunching(_ notification: Notification) {
        let hosting = NSHostingController(rootView: Text("Settings live in SwiftUI"))
        let window = NSWindow(contentViewController: hosting)
        windowController = NSWindowController(window: window)
        windowController?.showWindow(nil)
        Task { await restoreSession() }
    }

    func application(_ application: NSApplication, open urls: [URL]) {
        pendingURLs.append(contentsOf: urls)   // may arrive before the controller or the restore
        drainPendingURLs()
    }

    private func restoreSession() async {
        // load the last workspace; reach this line on success, empty result AND error
        restoreFinished = true
        drainPendingURLs()
    }

    private func drainPendingURLs() {
        guard windowController != nil, restoreFinished else { return }
        let urls = pendingURLs
        pendingURLs.removeAll()
        for url in urls { windowController?.window?.title = url.lastPathComponent }
    }

    func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
        saveSnapshot()                 // every window is still open here
        terminating = true             // later per-window saves become no-ops
        return .terminateNow
    }

    func windowWillCloseSavingState() {
        guard !terminating else { return }
        saveSnapshot()
    }

    private func saveSnapshot() { /* write the session */ }
}

External links

Exercise

Spark Mac 앱에 URL 스킴이나 문서 타입을 주고, 코드 블록의 대기열 방식으로 application(_:open:)을 구현해. restoreSession에는 일부러 1초 지연을 넣어. 앱을 완전히 종료한 다음 터미널에서 open 'spark://reveal?item=42'로 URL을 열고, 복원이 끝난 뒤에 요청한 항목이 보이는지 확인해. 이어서 대기열을 없애 봐. 켜져 있을 땐 여전히 되는데, 꺼진 상태에서 실행하면 실패하는 걸 볼 수 있어.
Hint
URL 스킴을 등록하려면 번들 Info.plist에 CFBundleURLTypes가 필요해서, 이 연습은 6트랙의 패키징된 .app이 있어야 해. 아직 거기까지 안 갔으면 run() 전에 main()에서 핸들러를 불러 경합을 흉내 내.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인 — 댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.