スクリーンタイム制御アプリ — 集中力管理、ペアレンタルコントロール、SNS依存からの脱却 — は、ストアで毎月のように新しいヒット作が生まれている数少ないジャンルの一つです。私自身、Rork で個人開発を続けるなかで「特定のアプリを時間帯で自動ロックするだけのシンプルなアプリ」を作ろうとして、最初の壁にぶつかりました。Rork のデフォルトテンプレートでは、これらのアプリの根幹をなす Apple のフレームワーク(Family Controls / DeviceActivity / ManagedSettings)が一切使えないのです。
理由は明確で、これら3つのフレームワークはいずれも「Family Controls エンタイトルメント」と呼ばれる Apple から個別申請して取得する特権が必要であり、加えて App Extension(Background Process)との連携が前提になっているためです。Rork Max のように React Native + Expo ベースで動く環境では、デフォルトで Native Module が用意されていない領域に踏み込むと、自分でカスタムモジュールを書く必要があります。
このガイドでは、私が実際に Rork Max で「フォーカスタイム」系アプリを実装する過程で得た知見を、エンタイトルメント取得から App Store 審査通過まで、全工程を通して解説します。React Native のカスタムネイティブモジュール経験がある方を対象としていますが、初めての方でも順を追って実装できる構成にしています。
なぜ Rork デフォルトではスクリーンタイム系アプリが作れないのか
Rork Max の標準ビルドが提供しているのは、Expo SDK にバンドルされている標準的な機能群(カメラ、通知、位置情報、ストレージなど)です。スクリーンタイム制御に必要な3フレームワークは、ここに含まれていません。
具体的には、以下の制約に直面します。
FamilyControls フレームワークは Apple のプライベート許可モデルで動作し、エンタイトルメント com.apple.developer.family-controls が無いとビルド自体が通りません
DeviceActivity フレームワークは「時間帯になったらコールバックする」処理を、メインアプリではなく App Extension(DeviceActivityMonitor Extension)として実装することを要求します
ManagedSettings でアプリをブロックする処理は、メインアプリ・Extension の双方からアクセスできる App Group 経由でのデータ共有を前提にしています
Rork で「単なる UI 制御アプリ」を作る分には Vibe Coding でほぼ完結できますが、スクリーンタイム制御は OS レベルの権限・Extension・データ共有という3つのレイヤーを跨ぐため、必然的にネイティブモジュールの実装と Xcode 側の設定が発生します。ここを誤魔化さずに、最初から「Rork + Custom Native Module + Extension Target」という構成で組むのが、結局いちばんの近道でした。
全体アーキテクチャ — 3つのフレームワークの役割を整理する
実装に入る前に、それぞれの役割を整理しておきましょう。混同するとあとで必ず詰まります。
FamilyControls は「ユーザーから利用許可を得る」「ユーザーに制御対象アプリを選ばせる」役割を担います。FamilyActivityPicker がコアの SwiftUI コンポーネントで、ユーザーがアプリを選んだ結果は FamilyActivitySelection というプライバシー保護された不透明なトークンとして返ってきます
DeviceActivity は「特定の時間帯(スケジュール)になったら起動する」「特定のしきい値(例: 30分使ったら)に達したらコールバックする」というスケジューリング機構です。実体は App Extension で、メインアプリが起動していなくても OS から呼び出されます
ManagedSettings は「実際にアプリをブロックする」「シールド画面を出す」役割を担います。ManagedSettingsStore に対して FamilyActivitySelection を渡すと、選ばれたアプリに即座にシールドがかかります
つまり「ユーザーに対象を選ばせる(FamilyControls)→ スケジュールを登録する(DeviceActivity)→ 時間が来たら自動的にブロック(ManagedSettings)」という3段ロケットの構造です。
Rork プロジェクトに組み込む構成
私が落ち着いた構成は次の通りです。
メインアプリ(Rork Max ビルド) : React Native UI、カスタムネイティブモジュール経由で Swift 側の API を呼び出す
Native Module(Swift) : FamilyControls の認可リクエスト、ピッカー表示、スケジュール登録、シールド適用のブリッジ
DeviceActivityMonitor Extension(Swift) : メインアプリとは別ターゲット。スケジュール開始/終了時のコールバックを受け、ManagedSettings 経由でシールドを適用
App Group : group.{your-bundle-id}.shield をメインアプリと Extension の両方に追加し、UserDefaults(suiteName:) で FamilyActivitySelection を共有
ステップ1: エンタイトルメントの取得と Xcode 設定
Family Controls エンタイトルメントは、開発者アカウントから「Request Special Capability」で個別に申請します。私の場合、申請から承認までおおよそ3〜5営業日かかりました。承認されると、Apple Developer Portal の Identifier 編集画面に「Family Controls (Distribution)」のチェックボックスが現れます。
承認前でも開発はできますが、その場合は com.apple.developer.family-controls を Debug 用のプロビジョニングプロファイルにしか付けられません。App Store Connect にアップロードするビルドには Distribution 用エンタイトルメントが必須なので、申請は早めに出すことを強くおすすめします。
Xcode 側では、メインアプリと Extension の双方の .entitlements に下記を追加します。
<!-- ScreenTimeControl.entitlements (メインアプリ・Extension 共通) -->
<? xml version = "1.0" encoding = "UTF-8" ?>
<! DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
< plist version = "1.0" >
< dict >
< key >com.apple.developer.family-controls</ key >
< true />
< key >com.apple.security.application-groups</ key >
< array >
< string >group.com.example.screentimecontrol.shield</ string >
</ array >
</ dict >
</ plist >
期待される結果: ビルド時に「Provisioning profile doesn't include the com.apple.developer.family-controls entitlement」エラーが出なくなります。出る場合は、エンタイトルメント申請が承認されていないか、プロビジョニングプロファイルの再生成が必要です。
ステップ2: Rork Max で Custom Native Module を作る
Rork Max でカスタムネイティブモジュールを使う方法はいくつかありますが、私が好むのは Expo Modules API を使う方法です。config-plugin で自動的に Xcode プロジェクトに統合できるため、eas build 時に毎回手動設定し直す必要がありません。
# プロジェクトルートで実行
npx create-expo-module@latest --local modules/screen-time-control
これで modules/screen-time-control/ ディレクトリが生成されます。ios/ScreenTimeControlModule.swift を以下のように書き換えます。
// modules/screen-time-control/ios/ScreenTimeControlModule.swift
import ExpoModulesCore
import FamilyControls
import ManagedSettings
import DeviceActivity
public class ScreenTimeControlModule : Module {
// App Group 経由でメイン↔Extension 間でデータ共有
private let appGroup = "group.com.example.screentimecontrol.shield"
private let store = ManagedSettingsStore ()
public func definition () -> ModuleDefinition {
Name ( "ScreenTimeControl" )
// 認可リクエスト: 初回起動時に1度だけ呼ぶ
AsyncFunction ( "requestAuthorization" ) { ( promise : Promise) in
Task {
do {
try await AuthorizationCenter.shared. requestAuthorization ( for : .individual)
promise. resolve ( true )
} catch {
promise. reject ( "AUTH_FAILED" , "Family Controls authorization failed: \( error. localizedDescription ) " )
}
}
}
// 認可状態を確認
Function ( "authorizationStatus" ) { () -> String in
switch AuthorizationCenter.shared.authorizationStatus {
case .approved : return "approved"
case .denied : return "denied"
case .notDetermined : return "notDetermined"
@unknown default: return "unknown"
}
}
// 制御スケジュールを登録(例: 22:00〜翌7:00 はシールド)
AsyncFunction ( "scheduleNightShield" ) { ( startHour : Int , endHour : Int , promise : Promise) in
let intervalStart = DateComponents ( hour : startHour)
let intervalEnd = DateComponents ( hour : endHour)
let schedule = DeviceActivitySchedule (
intervalStart : intervalStart,
intervalEnd : intervalEnd,
repeats : true
)
let center = DeviceActivityCenter ()
do {
try center. startMonitoring (.nightShield, during : schedule)
promise. resolve ( true )
} catch {
promise. reject ( "SCHEDULE_FAILED" , error.localizedDescription)
}
}
// スケジュール解除
AsyncFunction ( "stopShield" ) { () -> Bool in
DeviceActivityCenter (). stopMonitoring ([.nightShield])
let store = ManagedSettingsStore ()
store.shield.applications = nil
store.shield.applicationCategories = nil
return true
}
}
}
extension DeviceActivityName {
static let nightShield = Self ( "nightShield" )
}
ポイントは、Module 内で ManagedSettingsStore を使ってシールドを直接かけるのではなく、スケジュール登録のみを行うこと です。実際のシールド適用は、後述の Extension 側でスケジュール開始時のコールバックで行います。理由は、メインアプリは時間が来たときに起動していない可能性が高いためで、必ず Extension 経由で行う必要があります。
ステップ3: FamilyActivityPicker を JS から呼び出す
ユーザーに「ブロック対象のアプリを選ばせる」UI は、Apple が提供する FamilyActivityPicker をそのまま使うのが正解です。これを React Native 側のモーダルで再現しようとすると、トークン化の仕組みが破綻して動かなくなります。
// PickerView.swift - SwiftUI ラッパー
import SwiftUI
import FamilyControls
struct AppPickerView : View {
@State private var selection = FamilyActivitySelection ()
@Environment (\.dismiss) var dismiss
let onComplete: (FamilyActivitySelection) -> Void
var body: some View {
NavigationView {
FamilyActivityPicker ( selection : $selection)
. navigationBarItems (
leading : Button ( "キャンセル" ) { dismiss () },
trailing : Button ( "決定" ) {
onComplete (selection)
dismiss ()
}
)
}
}
}
このビューを Rork Max 側から呼び出すために、ネイティブモジュールにメソッドを追加します。
// ScreenTimeControlModule.swift に追記
AsyncFunction ( "presentAppPicker" ) { ( promise : Promise) in
DispatchQueue.main. async {
guard let rootVC = UIApplication.shared.connectedScenes
. compactMap ({ ( $0 as? UIWindowScene) ? .keyWindow ? .rootViewController })
. first else {
promise. reject ( "NO_VC" , "No root view controller" )
return
}
let picker = AppPickerView { selection in
// FamilyActivitySelection をエンコードして App Group に保存
let encoder = PropertyListEncoder ()
if let data = try? encoder. encode (selection),
let defaults = UserDefaults ( suiteName : self .appGroup) {
defaults. set (data, forKey : "blockedApps" )
promise. resolve ([
"applicationCount" : selection.applicationTokens. count ,
"categoryCount" : selection.categoryTokens. count
])
} else {
promise. reject ( "ENCODE_FAILED" , "Could not encode selection" )
}
}
let host = UIHostingController ( rootView : picker)
rootVC. present (host, animated : true )
}
}
期待される結果: React Native 側から await ScreenTimeControl.presentAppPicker() を呼ぶと、Apple 純正のアプリピッカーが立ち上がり、ユーザーがアプリを選んで「決定」を押すと、選ばれたアプリ数とカテゴリ数が JS に返ってきます。実際のトークン情報はプライバシー保護のため JS には渡せず、App Group 経由で Extension に共有します。
ステップ4: DeviceActivityMonitor Extension の実装
ここが一番のキモです。スケジュールが始まったときに OS から呼ばれるコールバックを実装します。
Xcode で File > New > Target > DeviceActivityMonitor Extension を選び、ターゲットを追加します。生成された DeviceActivityMonitorExtension.swift を以下のように書き換えます。
// DeviceActivityMonitorExtension.swift
import DeviceActivity
import ManagedSettings
import Foundation
class DeviceActivityMonitorExtension : DeviceActivityMonitor {
let store = ManagedSettingsStore ()
let appGroup = "group.com.example.screentimecontrol.shield"
override func intervalDidStart ( for activity: DeviceActivityName) {
super . intervalDidStart ( for : activity)
// App Group から FamilyActivitySelection を取り出す
guard let defaults = UserDefaults ( suiteName : appGroup),
let data = defaults. data ( forKey : "blockedApps" ) else {
return
}
let decoder = PropertyListDecoder ()
guard let selection = try? decoder. decode (FamilyActivitySelection. self , from : data) else {
return
}
// シールドをかける
store.shield.applications = selection.applicationTokens. isEmpty
? nil : selection.applicationTokens
store.shield.applicationCategories = selection.categoryTokens. isEmpty
? nil : . specific (selection.categoryTokens)
}
override func intervalDidEnd ( for activity: DeviceActivityName) {
super . intervalDidEnd ( for : activity)
// 時間帯終了時にシールドを外す
store.shield.applications = nil
store.shield.applicationCategories = nil
}
}
この Extension は OS によってバックグラウンドで起動されます。実行時間は数秒以内に制限されているため、ネットワーク通信や重い処理は厳禁です。やるべきは「保存済みの選択を取り出す」「シールドを当てる/外す」だけ と割り切ることが大切です。
ステップ5: React Native 側からの呼び出し
ここまで来れば、JS 側はシンプルに書けます。
// app/screens/ScheduleScreen.tsx
import { useEffect, useState } from "react" ;
import { View, Text, Button, Alert } from "react-native" ;
import ScreenTimeControl from "@/modules/screen-time-control" ;
export default function ScheduleScreen () {
const [ authStatus , setAuthStatus ] = useState < string >( "notDetermined" );
const [ pickedCount , setPickedCount ] = useState ( 0 );
useEffect (() => {
setAuthStatus (ScreenTimeControl. authorizationStatus ());
}, []);
const handleAuthorize = async () => {
try {
await ScreenTimeControl. requestAuthorization ();
setAuthStatus (ScreenTimeControl. authorizationStatus ());
} catch (e) {
Alert. alert (
"許可が必要です" ,
"設定 > スクリーンタイム から本アプリを許可してください"
);
}
};
const handlePick = async () => {
const result = await ScreenTimeControl. presentAppPicker ();
setPickedCount (result.applicationCount + result.categoryCount);
};
const handleSchedule = async () => {
// 22:00〜翌7:00 までシールド
await ScreenTimeControl. scheduleNightShield ( 22 , 7 );
Alert. alert ( "夜間シールドを有効化しました" );
};
return (
< View style = {{ padding : 16 , gap : 12 }} >
< Text > 認可状態 : { authStatus }</ Text >
{ authStatus !== " approved " ? (
< Button title = "スクリーンタイム機能を許可" onPress = {handleAuthorize} />
) : (
<>
< Button title = "ブロック対象アプリを選ぶ" onPress = {handlePick} />
< Text > 選択中 : { pickedCount } 項目 </ Text >
< Button title = "22:00〜7:00 シールドをON" onPress = {handleSchedule} />
< Button
title = "シールド解除"
onPress = {() => ScreenTimeControl.stopShield()}
/>
</>
)}
</ View >
);
}
期待される動作: 初回起動時に「認可ボタン」を押すと OS のスクリーンタイムダイアログが出る → 許可後にアプリピッカーが選択可能 → スケジュールを登録すると、22時を過ぎた瞬間に選んだアプリが OS レベルでブロックされてシールド画面が表示される、という一連のフローが動きます。
よくある間違い・落とし穴
このセクションは、私が実際に詰まった点を網羅したものです。情報が散逸していて、Apple 公式ドキュメントだけでは見落としがちなポイントばかりです。
1. 「FamilyActivitySelection を JS に渡そうとして失敗する」
FamilyActivitySelection の中身(applicationTokens など)はプライバシー保護のためメインアプリのプロセスからしか参照できません 。JS 側に「どのアプリが選ばれたか」を文字列やバンドル ID として渡すことは原理的にできない設計になっています。私も最初に試して、JS でデバッグ表示が空っぽになって気づきました。正しい設計は、選ばれた数だけを返し、データ本体は App Group に保存しておく ことです。
2. 「シールドが時間になっても発動しない」
これは99%、Extension 側の Bundle ID とエンタイトルメントが正しく設定されていないことが原因です。チェックリストとしては以下を見直してください。
Extension のターゲットの Bundle ID が {メインアプリ}.deviceactivitymonitor のような子 ID になっているか
Extension の .entitlements に com.apple.developer.family-controls = true と App Group が両方含まれているか
メインアプリと Extension の両方が同じ App Group に追加されているか
Extension の Info.plist の NSExtensionPrincipalClass が $(PRODUCT_MODULE_NAME).DeviceActivityMonitorExtension に設定されているか
3. 「UserDefaults(suiteName:) で書いたデータが Extension から読めない」
App Group 名が完全一致していないと、UserDefaults は黙って nil を返します。エラーが出ないので発見が難しいです。私は group.com.example.screentimecontrol.shield と group.com.example.screentime.shield(途中の control を抜いた)で延々ハマりました。Xcode の Capability 設定画面で両ターゲットの App Group を文字列レベルでコピペ確認するのが確実です。
4. 「リリースビルドだけクラッシュする」
DeviceActivity Extension はメモリ制限が厳しく(およそ 6MB)、デバッグビルドでは通っていた処理がリリースビルドで crashed - jetsam で落ちることがあります。Extension 内で重い JSON エンコードやログ出力を避け、必要最小限の処理に絞ることがほぼ唯一の対策です。私は最初 Sentry を Extension にも入れていましたが、これがメモリ枯渇の原因でした。
5. 「ペアレンタルコントロール用途で .child 認可を使うとシミュレータで動かない」
requestAuthorization(for: .child) はシミュレータでは常にエラーを返します。実機での動作確認が必須です。さらに .child は実際の親子関係の検証を伴うため、テストには「親アカウントから子アカウントにファミリー共有を設定したテスト用 Apple ID」が必要です。これは Apple Developer の Sandbox Tester では作れず、本物のファミリー共有設定が必要なので、テスト体制の構築自体に時間がかかる点を見越しておくべきです。
App Store 審査を通すためのコツ
スクリーンタイム系アプリは Apple の審査が厳しいジャンルの一つです。私が複数回リジェクトを経験して学んだ、押さえるべきポイントをまとめます。
審査担当者に「このアプリは何を、どうやって、誰のために制御するのか」を一目で伝えることが最も重要です。Review Notes(審査ノート)に以下の3点を必ず明記してください。
アプリの目的(例: ユーザー本人の集中力サポート/未成年の保護者向けペアレンタルコントロール)
認可フロー(初回起動時にどの画面でユーザーに許可を求めるか)
データの取り扱い(FamilyActivitySelection はデバイス内のみで完結し、サーバーには送信しない旨)
加えて、NSFamilyControlsUsageDescription を Info.plist に必ず設定しましょう。これが空だと審査で確実に止まります。
< key >NSFamilyControlsUsageDescription</ key >
< string >選択したアプリの使用時間を制限するためにスクリーンタイム機能を使用します。情報はこの端末内でのみ使用されます。</ string >
私自身、最初のリジェクト理由は「ユーザーが許可した内容と実際の挙動が一致していない」というものでした。これは認可ダイアログの説明と、その後のシールド適用範囲がずれていたためです。ダイアログ文面と実装は1ピクセル単位で揃える、というくらいの厳密さが求められます。
全体を振り返って — まず動く最小構成を1日で作る
スクリーンタイム制御アプリは、Apple のフレームワーク・エンタイトルメント・Extension・App Group の4つが噛み合って初めて動く、現代の iOS 開発のなかでも複雑さの上位に位置するジャンルです。Rork Max で取り組む場合、UI とロジックの大半は React Native で書けますが、根幹のネイティブ呼び出しと Extension 部分は Swift で書く必要があり、ここで多くの開発者が止まります。
最初の一歩としては、今日まずやるべきは「Family Controls エンタイトルメント申請」と「DeviceActivityMonitor Extension のターゲット追加」の2つだけ です。エンタイトルメント承認まで数日かかるので、その間に本ガイドのコードを Rork プロジェクトに組み込んで、申請が通った瞬間にビルドを通せる状態にしておくのが効率的です。
スケジュール登録だけなら半日で動く構成まで持っていけます。そこからは、UI を整える・スケジュールを複数登録できるようにする・特定のWebサイトドメインもブロックする(store.webDomains)・統計表示(DeviceActivityReport)を加える、と機能を積み上げていけば、有償サブスクリプションとして十分成立する完成度のアプリに育ちます。
スクリーンタイム制御は、ユーザーの行動そのものに介入する重い責任を伴いますが、だからこそ価値の高いプロダクトを生み出しやすいジャンルでもあります。Rork Max のスピード感で UI とビジネスロジックを高速に試行錯誤しつつ、根幹の OS 連携部分だけネイティブで丁寧に作る — この組み合わせは、個人開発者にとって今もっとも武器になるアプローチの一つだと感じています。
React Native のカスタムモジュールを書く際にも、Swift 側の言語機能・メモリ管理・プロトコル指向の理解が直接効いてきます。
Rork での実装やリリースで詰まった際は、関連記事として Rork Max の Background Processing 実装ガイド と Rork Sentry でのクラッシュ監視セットアップ も併せて参照してみてください。Extension のメモリ問題やバックグラウンド実行の仕組みに関する理解が、本記事の内容を本番で安定運用するための土台になります。