callkit-voip by dpearson2699/swift-ios-skills
npx skills add https://github.com/dpearson2699/swift-ios-skills --skill callkit-voip使用 CallKit 和 PushKit 构建与原生 iOS 通话 UI 集成的 VoIP 通话功能。涵盖来电/去电流程、VoIP 推送注册、音频会话协调以及通话目录扩展。目标版本为 Swift 6.2 / iOS 26+。
| 类型 | 角色 |
|---|---|
CXProvider | 向系统报告通话,接收通话操作 |
CXCallController | 请求通话操作(开始、结束、保持、静音) |
广告位招租
在这里展示您的产品或服务
触达数万 AI 开发者,精准高效
CXCallUpdate | 描述通话元数据(呼叫方名称、视频、句柄) |
CXProviderDelegate | 处理系统通话操作和音频会话事件 |
PKPushRegistry | 注册并接收 VoIP 推送通知 |
在应用启动时创建一个单一的 CXProvider 实例,并在应用生命周期内保持其存活。使用描述您通话能力的 CXProviderConfiguration 来配置它。
import CallKit
/// CXProvider 将所有委托调用分派给传递给 `setDelegate(_:queue:)` 的队列。
/// `let` 属性初始化一次且永不改变,使得此类型尽管带有 @unchecked Sendable,也能安全地在并发域之间共享。
final class CallManager: NSObject, @unchecked Sendable {
static let shared = CallManager()
let provider: CXProvider
let callController = CXCallController()
private override init() {
let config = CXProviderConfiguration()
config.localizedName = "My VoIP App"
config.supportsVideo = true
config.maximumCallsPerCallGroup = 1
config.maximumCallGroups = 2
config.supportedHandleTypes = [.phoneNumber, .emailAddress]
config.includesCallsInRecents = true
provider = CXProvider(configuration: config)
super.init()
provider.setDelegate(self, queue: nil)
}
}
当 VoIP 推送到达时,立即向 CallKit 报告来电。系统将显示原生通话 UI。您必须在 PushKit 完成处理程序返回之前报告通话——否则将导致系统终止您的应用。
func reportIncomingCall(
uuid: UUID,
handle: String,
hasVideo: Bool
) async throws {
let update = CXCallUpdate()
update.remoteHandle = CXHandle(type: .phoneNumber, value: handle)
update.hasVideo = hasVideo
update.localizedCallerName = "Jane Doe"
try await withCheckedThrowingContinuation {
(continuation: CheckedContinuation<Void, Error>) in
provider.reportNewIncomingCall(
with: uuid,
update: update
) { error in
if let error {
continuation.resume(throwing: error)
} else {
continuation.resume()
}
}
}
}
实现 CXProviderDelegate 以响应用户接听:
extension CallManager: CXProviderDelegate {
func providerDidReset(_ provider: CXProvider) {
// 结束所有通话,重置音频
}
func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
// 配置音频,连接到通话服务器
configureAudioSession()
connectToCallServer(callUUID: action.callUUID)
action.fulfill()
}
func provider(_ provider: CXProvider, perform action: CXEndCallAction) {
disconnectFromCallServer(callUUID: action.callUUID)
action.fulfill()
}
}
使用 CXCallController 请求发起去电。系统通过您的 CXProviderDelegate 路由该请求。
func startOutgoingCall(handle: String, hasVideo: Bool) {
let uuid = UUID()
let handle = CXHandle(type: .phoneNumber, value: handle)
let startAction = CXStartCallAction(call: uuid, handle: handle)
startAction.isVideo = hasVideo
let transaction = CXTransaction(action: startAction)
callController.request(transaction) { error in
if let error {
print("Failed to start call: \(error)")
}
}
}
extension CallManager {
func provider(_ provider: CXProvider, perform action: CXStartCallAction) {
configureAudioSession()
// 开始连接到服务器
provider.reportOutgoingCall(
with: action.callUUID,
startedConnectingAt: Date()
)
connectToServer(callUUID: action.callUUID) {
provider.reportOutgoingCall(
with: action.callUUID,
connectedAt: Date()
)
}
action.fulfill()
}
}
在每次应用启动时注册 VoIP 推送。每当令牌更改时,将其发送到您的服务器。
import PushKit
final class PushManager: NSObject, PKPushRegistryDelegate {
let registry: PKPushRegistry
override init() {
registry = PKPushRegistry(queue: .main)
super.init()
registry.delegate = self
registry.desiredPushTypes = [.voIP]
}
func pushRegistry(
_ registry: PKPushRegistry,
didUpdate pushCredentials: PKPushCredentials,
for type: PKPushType
) {
let token = pushCredentials.token
.map { String(format: "%02x", $0) }
.joined()
// 将令牌发送到您的服务器
sendTokenToServer(token)
}
func pushRegistry(
_ registry: PKPushRegistry,
didReceiveIncomingPushWith payload: PKPushPayload,
for type: PKPushType,
completion: @escaping () -> Void
) {
guard type == .voIP else {
completion()
return
}
let callUUID = UUID()
let handle = payload.dictionaryPayload["handle"] as? String ?? "Unknown"
Task {
do {
try await CallManager.shared.reportIncomingCall(
uuid: callUUID,
handle: handle,
hasVideo: false
)
} catch {
// 通话被勿扰模式或阻止列表过滤
}
completion()
}
}
}
CallKit 管理音频会话的激活/停用。当 CallKit 通知您时再配置音频会话,而不是在此之前。
extension CallManager {
func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) {
// 音频会话现已激活 -- 启动音频引擎 / WebRTC
startAudioEngine()
}
func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) {
// 音频会话已停用 -- 停止音频引擎
stopAudioEngine()
}
func configureAudioSession() {
let session = AVAudioSession.sharedInstance()
do {
try session.setCategory(
.playAndRecord,
mode: .voiceChat,
options: [.allowBluetooth, .allowBluetoothA2DP]
)
} catch {
print("Audio session configuration failed: \(error)")
}
}
}
创建一个通话目录扩展以提供来电显示和通话阻止功能。
import CallKit
final class CallDirectoryHandler: CXCallDirectoryProvider {
override func beginRequest(
with context: CXCallDirectoryExtensionContext
) {
if context.isIncremental {
addOrRemoveIncrementalEntries(to: context)
} else {
addAllEntries(to: context)
}
context.completeRequest()
}
private func addAllEntries(
to context: CXCallDirectoryExtensionContext
) {
// 电话号码必须按升序排列(E.164 格式的 Int64)
let blockedNumbers: [CXCallDirectoryPhoneNumber] = [
18005551234, 18005555678
]
for number in blockedNumbers {
context.addBlockingEntry(
withNextSequentialPhoneNumber: number
)
}
let identifiedNumbers: [(CXCallDirectoryPhoneNumber, String)] = [
(18005551111, "Local Pizza"),
(18005552222, "Dentist Office")
]
for (number, label) in identifiedNumbers {
context.addIdentificationEntry(
withNextSequentialPhoneNumber: number,
label: label
)
}
}
}
数据更改后从主应用重新加载扩展:
CXCallDirectoryManager.sharedInstance.reloadExtension(
withIdentifier: "com.example.app.CallDirectory"
) { error in
if let error { print("Reload failed: \(error)") }
}
如果您的 PushKit 委托收到 VoIP 推送但未调用 reportNewIncomingCall(with:update:completion:),iOS 将终止您的应用,并可能完全停止推送传递。
// 错误 -- 未报告通话
func pushRegistry(
_ registry: PKPushRegistry,
didReceiveIncomingPushWith payload: PKPushPayload,
for type: PKPushType,
completion: @escaping () -> Void
) {
// 仅处理数据,未报告通话
processPayload(payload)
completion()
}
// 正确 -- 始终报告通话
func pushRegistry(
_ registry: PKPushRegistry,
didReceiveIncomingPushWith payload: PKPushPayload,
for type: PKPushType,
completion: @escaping () -> Void
) {
let uuid = UUID()
provider.reportNewIncomingCall(
with: uuid, update: makeUpdate(from: payload)
) { _ in completion() }
}
在 provider(_:didActivate:) 之前启动音频引擎会导致静音或立即停用。CallKit 管理与系统的会话优先级。
// 错误
func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
startAudioEngine() // 太早 -- 会话尚未激活
action.fulfill()
}
// 正确
func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
prepareAudioEngine() // 准备,但不要启动
action.fulfill()
}
func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) {
startAudioEngine() // 现在安全了
}
未履行或失败一个操作会使通话处于不确定状态并触发超时处理程序。
// 错误
func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
connectToServer()
// 忘记了 action.fulfill()
}
// 正确
func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
connectToServer()
action.fulfill()
}
VoIP 推送令牌可能随时更改。如果您的服务器持有过时的令牌,推送将静默失败,来电永远不会到达。
// 错误 -- 仅在首次注册时发送一次令牌
func pushRegistry(
_ registry: PKPushRegistry,
didUpdate pushCredentials: PKPushCredentials,
for type: PKPushType
) {
// 令牌本地保存但从未在服务器上更新
}
// 正确 -- 始终更新服务器
func pushRegistry(
_ registry: PKPushRegistry,
didUpdate pushCredentials: PKPushCredentials,
for type: PKPushType
) {
let token = pushCredentials.token.map { String(format: "%02x", $0) }.joined()
sendTokenToServer(token) // 始终发送到服务器
}
CXProvider 实例CXProviderDelegatereportNewIncomingCallaction.fulfill() 或 action.fail()provider(_:didActivate:) 回调之后启动音频引擎provider(_:didDeactivate:) 回调中停止音频引擎.playAndRecord,模式为 .voiceChatdidUpdate pushCredentials 回调中将 VoIP 推送令牌发送到服务器PKPushRegistry(非惰性创建)CXCallUpdate 填充了 localizedCallerName 和 remoteHandlestartedConnectingAt 和 connectedAt 时间戳references/callkit-patterns.md每周安装数
324
仓库
GitHub 星标数
269
首次出现
2026年3月8日
安全审计
安装于
codex321
opencode318
github-copilot318
amp318
cline318
kimi-cli318
Build VoIP calling features that integrate with the native iOS call UI using CallKit and PushKit. Covers incoming/outgoing call flows, VoIP push registration, audio session coordination, and call directory extensions. Targets Swift 6.2 / iOS 26+.
| Type | Role |
|---|---|
CXProvider | Reports calls to the system, receives call actions |
CXCallController | Requests call actions (start, end, hold, mute) |
CXCallUpdate | Describes call metadata (caller name, video, handle) |
CXProviderDelegate | Handles system call actions and audio session events |
PKPushRegistry | Registers for and receives VoIP push notifications |
Create a single CXProvider at app launch and keep it alive for the app lifetime. Configure it with a CXProviderConfiguration that describes your calling capabilities.
import CallKit
/// CXProvider dispatches all delegate calls to the queue passed to `setDelegate(_:queue:)`.
/// The `let` properties are initialized once and never mutated, making this type
/// safe to share across concurrency domains despite @unchecked Sendable.
final class CallManager: NSObject, @unchecked Sendable {
static let shared = CallManager()
let provider: CXProvider
let callController = CXCallController()
private override init() {
let config = CXProviderConfiguration()
config.localizedName = "My VoIP App"
config.supportsVideo = true
config.maximumCallsPerCallGroup = 1
config.maximumCallGroups = 2
config.supportedHandleTypes = [.phoneNumber, .emailAddress]
config.includesCallsInRecents = true
provider = CXProvider(configuration: config)
super.init()
provider.setDelegate(self, queue: nil)
}
}
When a VoIP push arrives, report the incoming call to CallKit immediately. The system displays the native call UI. You must report the call before the PushKit completion handler returns -- failure to do so causes the system to terminate your app.
func reportIncomingCall(
uuid: UUID,
handle: String,
hasVideo: Bool
) async throws {
let update = CXCallUpdate()
update.remoteHandle = CXHandle(type: .phoneNumber, value: handle)
update.hasVideo = hasVideo
update.localizedCallerName = "Jane Doe"
try await withCheckedThrowingContinuation {
(continuation: CheckedContinuation<Void, Error>) in
provider.reportNewIncomingCall(
with: uuid,
update: update
) { error in
if let error {
continuation.resume(throwing: error)
} else {
continuation.resume()
}
}
}
}
Implement CXProviderDelegate to respond when the user answers:
extension CallManager: CXProviderDelegate {
func providerDidReset(_ provider: CXProvider) {
// End all calls, reset audio
}
func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
// Configure audio, connect to call server
configureAudioSession()
connectToCallServer(callUUID: action.callUUID)
action.fulfill()
}
func provider(_ provider: CXProvider, perform action: CXEndCallAction) {
disconnectFromCallServer(callUUID: action.callUUID)
action.fulfill()
}
}
Use CXCallController to request an outgoing call. The system routes the request through your CXProviderDelegate.
func startOutgoingCall(handle: String, hasVideo: Bool) {
let uuid = UUID()
let handle = CXHandle(type: .phoneNumber, value: handle)
let startAction = CXStartCallAction(call: uuid, handle: handle)
startAction.isVideo = hasVideo
let transaction = CXTransaction(action: startAction)
callController.request(transaction) { error in
if let error {
print("Failed to start call: \(error)")
}
}
}
extension CallManager {
func provider(_ provider: CXProvider, perform action: CXStartCallAction) {
configureAudioSession()
// Begin connecting to server
provider.reportOutgoingCall(
with: action.callUUID,
startedConnectingAt: Date()
)
connectToServer(callUUID: action.callUUID) {
provider.reportOutgoingCall(
with: action.callUUID,
connectedAt: Date()
)
}
action.fulfill()
}
}
Register for VoIP pushes at every app launch. Send the token to your server whenever it changes.
import PushKit
final class PushManager: NSObject, PKPushRegistryDelegate {
let registry: PKPushRegistry
override init() {
registry = PKPushRegistry(queue: .main)
super.init()
registry.delegate = self
registry.desiredPushTypes = [.voIP]
}
func pushRegistry(
_ registry: PKPushRegistry,
didUpdate pushCredentials: PKPushCredentials,
for type: PKPushType
) {
let token = pushCredentials.token
.map { String(format: "%02x", $0) }
.joined()
// Send token to your server
sendTokenToServer(token)
}
func pushRegistry(
_ registry: PKPushRegistry,
didReceiveIncomingPushWith payload: PKPushPayload,
for type: PKPushType,
completion: @escaping () -> Void
) {
guard type == .voIP else {
completion()
return
}
let callUUID = UUID()
let handle = payload.dictionaryPayload["handle"] as? String ?? "Unknown"
Task {
do {
try await CallManager.shared.reportIncomingCall(
uuid: callUUID,
handle: handle,
hasVideo: false
)
} catch {
// Call was filtered by DND or block list
}
completion()
}
}
}
CallKit manages audio session activation/deactivation. Configure your audio session when CallKit tells you to, not before.
extension CallManager {
func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) {
// Audio session is now active -- start audio engine / WebRTC
startAudioEngine()
}
func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) {
// Audio session deactivated -- stop audio engine
stopAudioEngine()
}
func configureAudioSession() {
let session = AVAudioSession.sharedInstance()
do {
try session.setCategory(
.playAndRecord,
mode: .voiceChat,
options: [.allowBluetooth, .allowBluetoothA2DP]
)
} catch {
print("Audio session configuration failed: \(error)")
}
}
}
Create a Call Directory extension to provide caller ID and call blocking.
import CallKit
final class CallDirectoryHandler: CXCallDirectoryProvider {
override func beginRequest(
with context: CXCallDirectoryExtensionContext
) {
if context.isIncremental {
addOrRemoveIncrementalEntries(to: context)
} else {
addAllEntries(to: context)
}
context.completeRequest()
}
private func addAllEntries(
to context: CXCallDirectoryExtensionContext
) {
// Phone numbers must be in ascending order (E.164 format as Int64)
let blockedNumbers: [CXCallDirectoryPhoneNumber] = [
18005551234, 18005555678
]
for number in blockedNumbers {
context.addBlockingEntry(
withNextSequentialPhoneNumber: number
)
}
let identifiedNumbers: [(CXCallDirectoryPhoneNumber, String)] = [
(18005551111, "Local Pizza"),
(18005552222, "Dentist Office")
]
for (number, label) in identifiedNumbers {
context.addIdentificationEntry(
withNextSequentialPhoneNumber: number,
label: label
)
}
}
}
Reload the extension from the main app after data changes:
CXCallDirectoryManager.sharedInstance.reloadExtension(
withIdentifier: "com.example.app.CallDirectory"
) { error in
if let error { print("Reload failed: \(error)") }
}
If your PushKit delegate receives a VoIP push but does not call reportNewIncomingCall(with:update:completion:), iOS terminates your app and may stop delivering pushes entirely.
// WRONG -- no call reported
func pushRegistry(
_ registry: PKPushRegistry,
didReceiveIncomingPushWith payload: PKPushPayload,
for type: PKPushType,
completion: @escaping () -> Void
) {
// Just process data, no call reported
processPayload(payload)
completion()
}
// CORRECT -- always report a call
func pushRegistry(
_ registry: PKPushRegistry,
didReceiveIncomingPushWith payload: PKPushPayload,
for type: PKPushType,
completion: @escaping () -> Void
) {
let uuid = UUID()
provider.reportNewIncomingCall(
with: uuid, update: makeUpdate(from: payload)
) { _ in completion() }
}
Starting your audio engine before provider(_:didActivate:) causes silence or immediate deactivation. CallKit manages session priority with the system.
// WRONG
func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
startAudioEngine() // Too early -- session not active yet
action.fulfill()
}
// CORRECT
func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
prepareAudioEngine() // Prepare, but do not start
action.fulfill()
}
func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) {
startAudioEngine() // Now it's safe
}
Failing to fulfill or fail an action leaves the call in a limbo state and triggers the timeout handler.
// WRONG
func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
connectToServer()
// Forgot action.fulfill()
}
// CORRECT
func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
connectToServer()
action.fulfill()
}
The VoIP push token can change at any time. If your server has a stale token, pushes silently fail and incoming calls never arrive.
// WRONG -- only send token once at first registration
func pushRegistry(
_ registry: PKPushRegistry,
didUpdate pushCredentials: PKPushCredentials,
for type: PKPushType
) {
// Token saved locally but never updated on server
}
// CORRECT -- always update server
func pushRegistry(
_ registry: PKPushRegistry,
didUpdate pushCredentials: PKPushCredentials,
for type: PKPushType
) {
let token = pushCredentials.token.map { String(format: "%02x", $0) }.joined()
sendTokenToServer(token) // Always send to server
}
CXProvider instance created at app launch and retainedCXProviderDelegate set before reporting any callsreportNewIncomingCall callaction.fulfill() or action.fail() called for every provider delegate actionprovider(_:didActivate:) callbackprovider(_:didDeactivate:) callback.playAndRecord with .voiceChat modereferences/callkit-patterns.mdWeekly Installs
324
Repository
GitHub Stars
269
First Seen
Mar 8, 2026
Security Audits
Gen Agent Trust HubPassSocketPassSnykPass
Installed on
codex321
opencode318
github-copilot318
amp318
cline318
kimi-cli318
AI音乐生成工具 - 使用inference.sh CLI快速生成歌曲、配乐和背景音乐
7,400 周安装
didUpdate pushCredentials callbackPKPushRegistry created at every app launch (not lazily)CXCallUpdate populated with localizedCallerName and remoteHandlestartedConnectingAt and connectedAt timestamps