0
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

SwiftUI + WidgetKitでOpenAIの障害を見逃さないmacOSアプリを作った

0
Last updated at Posted at 2026-08-13

OpenAIのサービスに障害が起きているのか、それとも自分の環境だけの問題なのか。切り分けのためにステータスページを開くことが増えたので、公式ステータスをmacOS上で確認できる非公式アプリを作りました。

この記事では、次の機能を持つmacOSアプリの構成と、実装時につまずいたポイントを紹介します。

  • SwiftUI製のステータス一覧画面
  • デスクトップ/通知センター用WidgetKitウィジェット
  • macOS 26以降のコントロールセンター用ControlWidget
  • 状態変化を検知するmacOS通知
  • 通知イベントと対象サービスの個別設定

本アプリは個人開発の非公式ツールです。OpenAIによる提供・承認・提携を示すものではありません。

完成したもの

アプリ本体はOpenAI全体の状態、各サービスの状態、進行中の障害、最終更新時刻を表示します。通常のWidgetKitウィジェットでは小・中サイズに対応し、macOS 26以降ではコントロールセンターにも現在の状態を表示できます。

監視はアプリ起動中に1分間隔で行います。ウインドウを閉じてもプロセスが動いていれば監視は継続し、アプリを終了すると停止します。

通知の初期値は次のようにしました。

項目 デフォルト
新しい障害 ON
サービスの性能低下・停止 ON
障害・サービスの復旧 ON
障害情報の更新 OFF
ステータス取得失敗 OFF
通知対象サービス すべて

更新情報や一時的な通信失敗まで通知すると騒がしくなりやすいため、重要度の高い変化だけを初期状態で有効にしています。

環境

  • Swift 6
  • SwiftUI
  • WidgetKit
  • UserNotifications
  • AppIntents
  • macOS 14.0以上
  • ControlWidgetのみmacOS 26.0以上
  • Xcode 26.6でDebug/Releaseビルドを確認

Xcodeなしでインストール

ソースコードとビルド済みアプリをGitHubで公開しています。

ビルド済みアプリはmacOS 14以上のAppleシリコンMacとIntel Macに対応するUniversal形式です。コントロールセンター用ControlWidgetだけはmacOS 26以上で利用できます。

ブラウザから導入する場合は、次の手順です。

  1. 最新ReleaseからOpenAIStatusMac-unnotarized.zipをダウンロードする
  2. ZIPを展開する
  3. OpenAI Status.appを「アプリケーション」フォルダへ移動する
  4. アプリを一度開く
  5. macOSに止められた場合は「システム設定 → プライバシーとセキュリティ」でOpenAI Statusの「このまま開く」を押す
  6. アプリを起動し、通知を許可する

この無料版はApple Development証明書で署名していますが、Appleの公証は受けていません。そのため、初回だけGatekeeperの手動許可が必要です。Gatekeeperを無効化したり、quarantine属性を削除したりする必要はありません。

ReleaseにはOpenAIStatusMac-unnotarized.zip.sha256も添付しています。Git経由なら、次のインストーラーが同じRelease ZIPをダウンロードし、SHA-256を確認して~/Applicationsへ配置します。

git clone https://github.com/koh-nakagawa/OpenAIStatusMac.git
cd OpenAIStatusMac
./scripts/install.sh

アプリを一度起動した後、デスクトップを右クリックして「ウィジェットを編集」を開き、OpenAI Statusを検索すると追加できます。macOS 26以降では、コントロールセンターの編集画面からControlWidgetも追加できます。

配布ZIPについては、次を確認しました。

  • ホストアプリと内包Widget Extensionの署名が有効で、Team IDが一致する
  • ホストアプリとWidget Extensionの両方がarm64x86_64を含む
  • Widget ExtensionのApp Sandboxと外向きネットワーク権限を保持する
  • Releaseバイナリに開発用のget-task-allow権限を含めない
  • ZIP展開後も署名検証とSHA-256検証が成功する
  • 未公証なのでspctlでは拒否され、上記の初回手動許可が必要になる

ソースからビルドする場合

開発用ビルドにはmacOS 14以上、Xcode 26以上、Git、Xcodeへ追加したApple IDが必要です。ターミナルで次を実行すると、ソースを取得し、テスト、公式APIのライブ取得、署名なしDebugビルドを確認した後にXcodeが開きます。

git clone https://github.com/koh-nakagawa/OpenAIStatusMac.git
cd OpenAIStatusMac
./scripts/setup.sh

Xcodeが開いたら、次の手順で起動できます。

  1. 左側の青いプロジェクトアイコンOpenAIStatusMacを選ぶ
  2. TARGETSOpenAI StatusSigning & Capabilitiesを開く
  3. Teamに自分のApple IDのTeamを選ぶ
  4. OpenAIStatusWidgetにも同じTeamを設定する
  5. SchemeをOpenAI Status、実行先をMy Macにして▶︎を押す
  6. 初回の通知確認で「許可」を選ぶ

Bundle Identifierが利用できない場合は、アプリとWidget Extensionを自分用の一意な値へ変更します。Widget Extension側は親アプリ側の末尾に.Widgetを付ける形にすると分かりやすいです。

com.yourname.OpenAIStatusMonitor
com.yourname.OpenAIStatusMonitor.Widget

公開リポジトリを別ディレクトリへfresh cloneし、4テスト、公式APIから25コンポーネントのデコード、ホストアプリ+Widget ExtensionのDebugビルドが成功することも確認しています。

データ取得

ステータス情報は、OpenAIの公式ステータスページが公開しているJSONエンドポイントから取得します。

public enum OpenAIStatusClient {
    static let summaryURL = URL(
        string: "https://status.openai.com/api/v2/summary.json"
    )!
    static let incidentsURL = URL(
        string: "https://status.openai.com/api/v2/incidents.json"
    )!

    static func fetchSnapshot() async throws -> OpenAIStatusSnapshot {
        async let summary: StatusSummaryResponse = fetch(summaryURL)
        async let incidents: IncidentsResponse = fetch(incidentsURL)
        return try await OpenAIStatusSnapshot(
            summary: summary,
            incidents: incidents
        )
    }
}

2つのリクエストはasync letで並行実行します。APIキーやOpenAIアカウントへのログインは不要です。

レスポンスをそのままViewへ渡すのではなく、表示と通知で共通利用できるOpenAIStatusSnapshotへ変換しました。解決済みの障害を除外し、稼働中でないコンポーネントを抽出しておくと、UI側の分岐が単純になります。

全体構成

OpenAI Status API
        |
        v
OpenAIStatusClient
        |
        v
OpenAIStatusSnapshot
   |          |             |
   v          v             v
SwiftUI   WidgetKit   StatusNotificationPolicy
                              |
                              v
                    UNUserNotificationCenter

取得処理と状態モデルを共有し、アプリ本体、ウィジェット、通知判定が同じ状態を参照する構成です。

WidgetKitウィジェット

通常のデスクトップウィジェットはStaticConfigurationで実装し、小・中サイズを提供します。

struct OpenAIStatusWidget: Widget {
    let kind = "OpenAIStatusWidget"

    var body: some WidgetConfiguration {
        StaticConfiguration(kind: kind, provider: OpenAIStatusProvider()) { entry in
            OpenAIStatusWidgetView(entry: entry)
        }
        .configurationDisplayName("OpenAI Status")
        .description("OpenAI公式ステータスの稼働状況と障害情報を表示します。")
        .supportedFamilies([.systemSmall, .systemMedium])
    }
}

タイムラインは15分後の再取得をリクエストしています。ただし実際の更新時刻はWidgetKitが電力状況などを考慮して決めるため、リアルタイム監視はホストアプリ側へ持たせています。

macOS 26のControlWidget

通常のWidgetKitウィジェットと、コントロールセンターのコントロールは別実装です。ControlValueProviderで現在値を返し、ControlWidgetButtonを押すとアプリを開くようにしました。

@available(macOS 26.0, *)
private struct OpenAIStatusControl: ControlWidget {
    let kind = "OpenAIStatusControl"

    var body: some ControlWidgetConfiguration {
        StaticControlConfiguration(
            kind: kind,
            provider: OpenAIStatusControlProvider()
        ) { value in
            ControlWidgetButton(action: OpenAIStatusControlIntent()) {
                Label(value.title, systemImage: value.symbolName)
            }
        }
        .displayName("OpenAI Status")
        .description("OpenAIの稼働状況を確認し、アプリを開きます。")
    }
}

アプリ全体のDeployment TargetはmacOS 14のままにし、ControlWidgetだけを@available(macOS 26.0, *)if #availableで囲みました。これにより、古いmacOSでは通常のウィジェットだけが利用できます。

通知は「現在異常か」ではなく「前回から変わったか」で判定する

1分ごとに現在の異常を通知すると、同じ障害について毎分通知が届いてしまいます。そこで前回の成功時点をベースラインとして保存し、差分だけを通知するようにしました。

保存する情報は次の2種類です。

  • 障害IDと、その障害の最新更新ID
  • コンポーネントIDと、そのステータス
public struct StatusNotificationBaseline: Codable, Sendable, Equatable {
    public let activeIncidentUpdates: [String: String]
    public let componentStatuses: [String: String]

    public init(snapshot: OpenAIStatusSnapshot) {
        activeIncidentUpdates = Dictionary(
            uniqueKeysWithValues: snapshot.activeIncidents.map { incident in
                let updateID = incident.incidentUpdates
                    .max(by: { $0.displayAt < $1.displayAt })?.id ?? ""
                return (incident.id, updateID)
            }
        )
        componentStatuses = Dictionary(
            uniqueKeysWithValues: snapshot.components.map { ($0.id, $0.status) }
        )
    }
}

差分判定では、次のイベントを別々に抽出します。

  • 新しい障害
  • 既存障害の更新
  • 新たに影響を受けたサービス
  • 復旧したサービス
  • 解決済みになった障害

状態が前回と同じなら何も通知しません。取得失敗についても、連続失敗中の最初の1回だけ通知するようにしています。

通知対象をユーザーが選べるようにする

設定はUserDefaultsへ保存し、SwiftUIのToggleから変更できるようにしました。

イベント種別だけでなく、コンポーネントIDの集合を差分判定へ渡すことで、たとえばChatGPTだけ、APIだけ、といった選択ができます。

public struct StatusNotificationSelection: Sendable, Equatable {
    public var notifyNewIncidents: Bool
    public var notifyIncidentUpdates: Bool
    public var notifyComponentOutages: Bool
    public var notifyRecoveries: Bool
    public var monitoredComponentIDs: Set<String>?

    public func monitors(componentID: String) -> Bool {
        monitoredComponentIDs?.contains(componentID) ?? true
    }
}

初回取得で得られたコンポーネント一覧を保存し、設定画面に個別のトグルとして並べています。サービスが追加された場合も、次回取得時に一覧が更新されます。

macOS通知

通知にはUserNotificationsを利用します。

let center = UNUserNotificationCenter.current()

func requestPermission() async {
    _ = try? await center.requestAuthorization(
        options: [.alert, .badge, .sound]
    )
}

func deliver(title: String, body: String) {
    let content = UNMutableNotificationContent()
    content.title = title
    content.body = body
    content.sound = .default

    let request = UNNotificationRequest(
        identifier: UUID().uuidString,
        content: content,
        trigger: nil
    )
    center.add(request)
}

アプリが前面にある場合もバナーを表示したかったので、UNUserNotificationCenterDelegatewillPresent.banner.soundを返しています。

監視タスクをViewの寿命から切り離す

監視ループを画面の.taskだけに置くと、ウインドウを閉じたときに監視まで止まる可能性があります。そこでStatusMonitorAppが所有するStateObjectにし、Viewの表示状態と分離しました。

func start() {
    guard monitoringTask == nil else { return }

    monitoringTask = Task { [weak self] in
        guard let self else { return }
        await notificationManager.prepare()
        await refresh()

        while !Task.isCancelled {
            try? await Task.sleep(for: .seconds(60))
            guard !Task.isCancelled else { break }
            await refresh(silent: true)
        }
    }
}

この方式では、ウインドウを閉じてもアプリプロセスが生きている間は監視が続きます。一方、アプリを終了すれば監視も終了します。常駐型にする場合は、メニューバーアプリ化やログイン項目への登録も検討できます。

WidgetKit拡張が一覧に出なかった原因

今回もっとも時間を使ったのは、ビルド成功後もウィジェット一覧に表示されない問題でした。確認すべき点は次の2つです。

1. 親アプリと拡張の署名を揃える

親アプリと.appexでSigning Teamや証明書が異なると、次のエラーになります。

Embedded binary is not signed with the same certificate as the parent app.

XcodeのSigning & Capabilitiesで、アプリとWidget Extensionの両方に同じTeamを設定します。

2. Widget ExtensionをApp Sandboxへ入れる

署名を直しても一覧に出ない場合、Widget Extension側のSandbox設定を確認します。今回の拡張では外部HTTPS通信も行うため、次のentitlementが必要でした。

<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>

ビルドが成功することと、macOSがWidget Extensionを登録できることは別の確認項目です。codesignだけでなく、実機のウィジェットギャラリーに表示されるところまで確認するのが重要でした。

テスト

通知ロジックをUNUserNotificationCenterから分離したことで、状態の組み合わせをXCTestで確認できます。

テストした内容は次のとおりです。

  • 解決済み障害を除外できること
  • 正常時の判定
  • 正常 → 障害 → 同一状態 → 復旧の差分
  • 選択していないサービスを通知しないこと
DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer \
  xcrun swift test

DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer \
  xcrun swift run StatusVerifier

4テストがすべて成功し、公式エンドポイントのライブデコードも確認しました。加えて、Debug/Releaseのビルドと、親アプリ・埋め込みWidget Extensionの署名検証を行っています。

まとめ

SwiftUI、WidgetKit、UserNotificationsを組み合わせることで、OpenAIの稼働状態をmacOS上で確認し、必要な変化だけ通知するアプリを作れました。

実装上のポイントは次の4つです。

  1. APIレスポンスを共通のSnapshotへ変換する
  2. 通知は現在値ではなく前回値との差分で判定する
  3. 通知イベントと対象サービスを別々に選択できるようにする
  4. Widget Extensionは署名とApp Sandboxの両方を確認する

今後は、アプリを終了しても監視できる仕組み、履歴表示、通知から該当インシデントを直接開く導線などを追加したいと考えています。

参考

0
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?