playpark で開発している Ima. は、一緒にいる時間だけ、その場の全員でスマホを手放すための iOS アプリです。家族の食卓などで全員が「いま置こう」と合意したときだけセッションが始まり、相手から離れると自動で解ける。我慢で依存を断つ禁欲ツールというより、目の前の相手との時間を守るための合意ベースの仕掛けを目指しています。
そのセッションの「ロック」を実際に効かせているのが、iOS のスクリーンタイム系 API です。具体的には Family Controls と ManagedSettings。「セッション中は選んだアプリを全部開けなくする」という、アプリ使用制限そのものを自前のアプリから掛けています。この記事は、その Family Controls 実装をどう組んだかの記録です。読者として、スクリーンタイム制御やアプリ使用制限の機能を iOS で作ろうとしている開発者を想定しています。
Ima. は App Store にリリース済みです。
最初の壁: そもそも capability がないと API が動かない
スクリーンタイム API、ドキュメントの一覧だけ眺めると import して呼ぶだけで動きそうに見えます。実際どうなの?というと、最初の壁は 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 が入ったら、実行時にユーザーの認可を取ります。ここは AuthorizationCenter の出番です。Ima. では、認可ロジックを直接呼ばずに薄いプロトコル越しに包んでいます。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 の扱いです。将来 SDK に新しい認可ステータスが増えたとき、未知の値を「approved 相当」と楽観的に解釈すると、Shield が無効のままセッションが進む事故になりかねません。なので未知状態は .notDetermined に倒して、オンボーディングのゲートを閉じたまま(fail-closed)にしています。「分からないときは安全側に倒す」を、enum の網羅性チェックに寄せて担保しているわけです。
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. が起動しているとは限りません。そのとき OS は、登録された拡張ターゲットを単独で起こして「この画面どう出す?」「ボタン押されたけどどうする?」を問い合わせてきます。だから拡張は独立したプロセス・独立したバイナリとして存在する必要があります。
どの拡張がどの役割を担うかは 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. では「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)が参照できないので、同じオレンジを手で同期しています。Asset の色を変えたらこちらも追従させる、というメンテ上の地雷つきです(だからコメントで自分宛に注意書きを残してあります)。
もうひとつはアイコン画像のキャッシュです。Shield 拡張はアプリを開こうとするたびに呼ばれるうえ、メモリと実行時間の予算がかなりタイトです。毎回 App Group から 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 秒だけ遅延を入れて配送を安定させています(たった 0.1 秒のために拡張プロセスを生かし続ける、という妙な気の遣い方になります)。
もうひとつが通知許可です。通知が .denied や .notDetermined だと add(_:) は黙って失敗し、ユーザー視点では「ボタンを押しても何も起きない」になります。Ima. では事前に getNotificationSettings で許可状態を確かめ、許可がなければ通知をスキップして state restoration 側に復帰を委ねる設計にしています。通知はあくまで補助動線で、本体アイコンのタップ経由で必ず効く state restoration が主動線、という二段構えです。
実はこの通知経路、一度デグレを出しています。オンボーディングの許可要求をプリパーミッションのアラートに一本化した際、onAppear 側の許可要求を削ってしまい、「あとで」を選んだユーザーの許可が .notDetermined のまま固定される事態になりました。結果として Shield のボタンを押しても通知予約が黙ってスキップされ、復帰動線が機能しなくなる。修正では onAppear で許可を要求するかの判定を純粋関数に切り出してユニットテストで固定し、削ってしまった要求を戻しています。許可フローはこういう「片方を直すともう片方が死ぬ」が起きやすいので、判定ロジックをテスト可能な形に追い出しておくのは効きました。
セッション状態と 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()
}
ポイントは、選択が空のときは Shield を立てないガードです。アプリを 1 つも選んでいない状態で activate すると、実質 no-op なのに isActive だけ立ってしまう。それを避けて、選択が入るまでは実質的に合意待ちのまま据え置きます。緊急解除や「離れたら自動解除」は、最終的にこの reconcile() の .released 経路に合流して deactivate() を呼ぶ、という一本道に揃えてあります。近接判定や緊急ボタンの中身については別の記事で書いたので、ここでは「Shield 発動のトリガー」とだけ捉えてください。
ちなみに、ユーザーに見える文言からは "Shield" という単語を意識的に消しています。緊急解除のアクセシビリティヒントやオンボーディングの説明に内部実装語の "Shield" が漏れていたのを、「Ima. 中」「すべてのアプリを開けるようにします」に揃えて置換しました。実装の語彙とユーザーの語彙は別物、というだけの話ですが、こういう露出は見落としやすいところです。
審査をどう通すか: BLE なしで動く Review Mode
最後に、App Store 審査ならではの問題があります。Ima. は「相手の端末が物理的に近くにある」ことを BLE で確かめてからセッションを始めます。でも審査員の手元に、ペアになる 2 台目の端末はありません。BLE が繋がらない=セッションが始まらない=Shield が一度も発動しないまま、機能が確認できずに終わってしまう。
そこで、審査専用の Review Mode を用意しました。ima://review-demo?token=... というディープリンクで起動する隔離された導線で、本物の状態機械や合意フローはそのまま動かしつつ、外部依存だけをモックに差し替えます。BLE は実機に一切繋がず、Subject から手で近接値を流すモックに置換。
final class MockBLEPeerService: BLEPeerService {
private let proximitySubject = CurrentValueSubject<Proximity, Never>(.unknown)
// ...
func emitProximity(_ value: Proximity) { proximitySubject.send(value) }
}
Shield も、本番の ManagedSettingsStore を生成せず呼び出し回数だけ記録するスパイに差し替えます(審査端末に本物のロックを掛けない、Watch にも状態を配信しない、本番の永続層にも触らない、という隔離をコンストラクタ注入で構造的に保証しています)。さらに、デモ用のコーディネーターは課金エンタイトルメントのゲートからも外してあります。審査員がサンドボックス購入をしなくてもデモが回るようにするためで、ここを外し忘れると審査が購入待ちで詰みます。
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. では、このロックの「鍵」を誰が持つか——近接や合意の設計——のほうにむしろ本質があるので、そちらも別記事で触れています。



