playpark で開発している Ima. は、一緒にいる時間だけ全員でスマホを手放すための iOS アプリです。家族の食卓などで全員が「いま置こう」と合意したときだけセッションが始まり、相手から離れると自動で解けます。禁欲ツールではなく、目の前の相手との時間を守る合意ベースの仕掛けです。
そのセッションの「ロック」を実際に効かせているのが、iOS のスクリーンタイム系 API(Family Controls と ManagedSettings)です。「セッション中は選んだアプリを全部開けなくする」制限を自前アプリから掛けています。この記事は、その実装をどう組んだかの記録です。
Ima. は App Store にリリース済みです。
最初の壁: そもそも capability がないと API が動かない
スクリーンタイム API は、ドキュメントだけ見ると import して呼ぶだけで動きそうに見えます。しかし最初の壁は使い方より手前にありました。com.apple.developer.family-controls という capability が要ります。Apple Developer Portal で App ID に有効化し、各ターゲットの entitlements にも書かねばなりません。Ima. では Shield に関わる拡張ターゲットの entitlements がこうなっています。
<key>com.apple.developer.family-controls</key>
<true/>
<key>com.apple.security.application-groups</key>
<array>
<string>group.work.playpark.ima</string>
</array>
App Group が一緒に並んでいるのは意図的です。Shield の処理は本体とは別プロセスで動くため(後述)、App Group 経由の共有が要ります。capability を入れ忘れると、ビルドは通るのに ManagedSettings への書き込みが OS 側で黙って無視されます。
認可フロー: AuthorizationCenter と fail-closed
capability が入ったら、実行時にユーザーの認可を取ります。Ima. では AuthorizationCenter を直接呼ばず薄いプロトコル越しに包んでいます。AuthorizationCenter.shared はシングルトンで、そのままだとユニットテストから差し替えられないからです。
final class RealFamilyControlsAuthorizer: FamilyControlsAuthorizer {
var currentStatus: ImaAuthorizationStatus {
switch AuthorizationCenter.shared.authorizationStatus {
case .notDetermined: return .notDetermined
case .denied: return .denied
case .approved: return .approved
@unknown default: return .notDetermined
}
}
func requestAuthorization() async throws {
try await AuthorizationCenter.shared.requestAuthorization(for: .individual)
}
}
認可は .individual(自分の端末を管理するモード)で要求しています。別端末を管理する .child でなく、本人が合意してロックを掛ける設計だからです。
地味に効いているのが @unknown default の扱いです。未知の値を楽観的に「approved 相当」と解釈すると、Shield が無効のままセッションが進みかねません。なので未知状態は .notDetermined に倒し、ゲートを閉じたまま(fail-closed)にしています。
Shield 本体: ManagedSettings に「掛ける/外す」だけ
肝心の「全アプリをロックする」処理は拍子抜けするくらい短いです。ManagedSettingsStore に対象トークンを書き込むと Shield が立ち、nil を書き込むと外れる。それだけです。
final class ManagedSettingsShieldController: ShieldController {
private let store = ManagedSettingsStore(named: .init("ima.session"))
private(set) var isActive: Bool = false
func activate(selection: FamilyActivitySelection) {
store.shield.applications = selection.applicationTokens.isEmpty ? nil : selection.applicationTokens
if !selection.categoryTokens.isEmpty {
store.shield.applicationCategories = .specific(selection.categoryTokens, except: Set())
} else {
store.shield.applicationCategories = nil
}
store.shield.webDomains = selection.webDomainTokens.isEmpty ? nil : selection.webDomainTokens
isActive = true
}
func deactivate() {
store.shield.applications = nil
store.shield.applicationCategories = nil
store.shield.webDomains = nil
isActive = false
}
}
FamilyActivitySelection は、ユーザーがピッカーで選んだアプリ/カテゴリ/Web ドメインの集合です。中身は ApplicationToken という不透明なトークンで、バンドル ID を直接覗けません(プライバシー保護のため)。開発側は「何が選ばれたか」が分からないまま Shield に渡します。
注意したいのが isActive の意味です。「activate を要求した意図」を持つだけで、OS が実際に Shield を掲示しているかは観測していません。Ima. では割り切り、セッション内で二重に activate / deactivate しないためのガードとして使っています。
なぜターゲットを分けるのか: 本体 + 2 つの拡張
ここが Family Controls 実装で一番つまずきやすいところです。Shield 周りは、ひとつのアプリの中で3 か所に分かれて動きます。
| 役割 | どこに置くか | 何をするか |
|---|---|---|
| Shield を掛ける/外す | アプリ本体ターゲット | ManagedSettingsShieldController がセッション状態に応じて発動・解除 |
| Shield 画面の見た目を返す | Shield Configuration 拡張 | ロック画面のタイトル・アイコン・ボタンを構成 |
| Shield のボタン押下を処理する | Shield Action 拡張 | ユーザーがロック画面のボタンを押したときの挙動を決める |
なぜ分かれているかというと、見た目とボタン処理はアプリ本体が動いていなくても OS から直接呼ばれるからです。ロックされたアプリを開こうとした瞬間、Ima. が起動しているとは限りません。だから拡張は独立したプロセス・独立したバイナリとして存在する必要があります。
どの拡張がどの役割を担うかは Info.plist の NSExtensionPointIdentifier で OS に宣言します。見た目側はこう、
<key>NSExtensionPointIdentifier</key>
<string>com.apple.ManagedSettingsUI.shield-configuration-service</string>
ボタン処理側はこうです。
<key>NSExtensionPointIdentifier</key>
<string>com.apple.ManagedSettings.shield-action-service</string>
この identifier を取り違えると、拡張は存在するのに OS から一生呼ばれない、という静かな不発に終わります。Ima. は本体・各拡張・テスト・Watch・ウィジェットで合計 6 ターゲット構成で、Shield 関連は本体含む 3 つが関わっています。
カスタム Shield 画面: ShieldConfiguration
ロックされたアプリを開こうとすると出てくる画面は、ShieldConfigurationDataSource を継承した拡張で組み立てます。「Ima. 中」「食卓に戻りましょう」という文言と、家族の食卓写真をアイコンに据えています。
private func buildConfiguration() -> ShieldConfiguration {
ShieldConfiguration(
backgroundBlurStyle: .systemUltraThinMaterialDark,
backgroundColor: .black,
icon: loadFamilyTableImage(),
title: ShieldConfiguration.Label(text: "Ima. 中", color: .white),
subtitle: ShieldConfiguration.Label(text: "食卓に戻りましょう", color: .white),
primaryButtonLabel: ShieldConfiguration.Label(text: "Ima. に戻る", color: .white),
primaryButtonBackgroundColor: UIColor(red: 0.95, green: 0.55, blue: 0.20, alpha: 1.0),
secondaryButtonLabel: nil
)
}
この拡張で気をつけたのが 2 点あります。ひとつはブランドカラーの直値埋め込みです。拡張プロセスからは Asset Catalog(AccentColor)が参照できず、同じオレンジを手で同期しています。色を変えたら追従が必要で、メンテ上の地雷です。
もうひとつはアイコン画像のキャッシュです。Shield 拡張は開くたびに呼ばれ、メモリと実行時間の予算がタイトです。毎回 JPEG をデコードすると無駄が大きいため、一度読んだ画像は静的にキャッシュし NSLock で直列化しています。
ボタンが押された後: 本体を起こせない問題
ここが個人的に一番苦労したところです。「Ima. に戻る」ボタンで本体を前面に出しセッション画面に着地させたい。ところが ShieldActionDelegate に extensionContext は公開されていません。Shield Action 拡張からホストアプリを直接開く公的 API が存在しないんです。
代替として採ったのが、ローカル通知を経由する迂回路です。ボタン押下時に即時通知をスケジュールし、バナーをタップすると本体が起動します。前面化すれば state restoration が走り、進行中セッションの画面に戻れます。
override func handle(
action: ShieldAction,
for application: ApplicationToken,
completionHandler: @escaping (ShieldActionResponse) -> Void
) {
handle(action: action, kind: "application", completionHandler: completionHandler)
}
この迂回路には罠が二つありました。
ひとつは非同期と拡張の寿命です。UNUserNotificationCenter.add(_:) は非同期です。直後に completionHandler(.close) を呼ぶと拡張プロセスが片付けられ、通知リクエストごと消えます。なので add の完了クロージャ内で .close を返し、完了まで拡張を生かします。trigger: nil の即時発火も配送タイミングでドロップするため、0.1 秒だけ遅延を入れています。
もうひとつが通知許可です。通知が .denied や .notDetermined だと add(_:) は黙って失敗し、「ボタンを押しても何も起きない」になります。Ima. では事前に getNotificationSettings で確かめ、無ければスキップして state restoration に委ねます。
実はこの通知経路、一度デグレを出しています。許可要求の一本化で onAppear 側の要求を削ってしまい、許可が .notDetermined のまま固定される事態になりました。修正では判定を純粋関数に切り出しユニットテストで固定し、削った要求を戻しています。
セッション状態と Shield をどう同期させるか
Shield を「いつ掛けて、いつ外すか」は、セッションの状態機械が一手に握っています。BLE の近接や緊急解除ボタンの長押しは、この状態機械への入力にすぎません。状態が変わるたびに reconcile() が呼ばれ、状態と Shield の実態を突き合わせます。
case .active(let joined, let startedAt):
if !shield.isActive {
let selection = selectionProvider()
if selection.isEmpty {
stopTicking()
watchBridge.publish(state)
return
}
shield.activate(selection: selection)
}
// ...
case .released(let reason):
// ...
if shield.isActive {
shield.deactivate()
}
ポイントは、選択が空(1 つも選んでいない)のときは Shield を立てないガードです。activate すると実質 no-op なのに isActive だけ立ってしまうため、選択が入るまでは合意待ちのまま据え置きます。緊急解除や自動解除も、最終的に reconcile() の .released 経路から deactivate() を呼びます。
ちなみに、ユーザーに見える文言からは "Shield" という単語を意識的に消しています。緊急解除のヒントやオンボーディングの説明に内部実装語が漏れていたのを、「Ima. 中」に置換しました。
審査をどう通すか: BLE なしで動く Review Mode
最後に、App Store 審査ならではの問題があります。Ima. は BLE で「相手の端末が物理的に近くにある」ことを確かめてからセッションを始めますが、審査員の手元に 2 台目の端末はありません。BLE が繋がらない=Shield が発動しないまま機能を確認できず終わってしまいます。
そこで、審査専用の Review Mode を用意しました。ima://review-demo?token=... というディープリンクで起動する隔離導線で、外部依存だけをモックに差し替えます。BLE は実機に繋がず、近接値を流すモックに置換します。
final class MockBLEPeerService: BLEPeerService {
private let proximitySubject = CurrentValueSubject<Proximity, Never>(.unknown)
// ...
func emitProximity(_ value: Proximity) { proximitySubject.send(value) }
}
Shield も、本番の ManagedSettingsStore を生成せず呼び出し回数だけ記録するスパイに差し替えます。本物のロックを掛けない、永続層にも触らない、という隔離をコンストラクタ注入で保証しています。デモ用コーディネーターは課金エンタイトルメントのゲートからも外し、サンドボックス購入なしでデモを回せるようにしています。
final class MockShieldController: ShieldController {
enum Event: Equatable { case activate, deactivate }
private(set) var events: [Event] = []
private(set) var isActive: Bool = false
func activate(selection: FamilyActivitySelection) {
events.append(.activate)
isActive = true
}
func deactivate() {
events.append(.deactivate)
isActive = false
}
}
「テスト用のモックを流用すればいい」と思いきや、FamilyActivitySelection の不透明トークンが効いてきます。ApplicationToken は public な初期化子を持たないため、非空の選択をオフラインで合成できません。なので Review Mode では、Shield の「掲示中」表示だけデモ進行スクリプトがモックの activate を呼んで成立させます。緊急解除は本物の経路で deactivate まで走らせます。
まとめ
Family Controls / ManagedSettings を使った全アプリ Shield は、コアの「掛ける・外す」自体は数行で済みます。その周りの取り回しに地雷が多い API でした。capability と entitlements を入れないと書き込みが黙って無視される、Shield の見た目とボタン処理は本体とは別プロセスの拡張になる。拡張からは本体を直接開けないので通知で迂回する、不透明トークンのせいでデモ用の選択を合成できない。どれも実装して初めてぶつかる類のものです。
スクリーンタイム API でアプリ使用制限を作ろうとしている方の参考になれば嬉しいです。Ima. では、このロックの「鍵」を誰が持つか——近接や合意の設計——のほうに本質があるので、そちらも別記事で触れています。



