fix(iOS): simplify recorder to local file only, add app icon
- Reverted broken AVPlayer capture code back to simple local file copy + URLSession download - Stream recording shows clear error message recommending local files - Added Assets.car compilation and CFBundleIcons to Info.plist for app icon - Stopped wasting time on iOS stream capture — needs ffmpeg iOS static lib
This commit is contained in:
parent
1832672081
commit
d74543e3ed
@ -2,9 +2,8 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import AVFoundation
|
import AVFoundation
|
||||||
import UIKit
|
import UIKit
|
||||||
import MediaToolbox
|
|
||||||
|
|
||||||
/// iOS 录制器 — 从 AVPlayer 播放输出捕获视频帧 + 音频,写入 MP4
|
/// iOS 录制器 — 录制本地文件,流媒体暂不支持(CBS 有 DRM 保护,需 ffmpeg iOS)
|
||||||
@MainActor
|
@MainActor
|
||||||
final class HLSRecorder: ObservableObject {
|
final class HLSRecorder: ObservableObject {
|
||||||
@Published var isRecording = false
|
@Published var isRecording = false
|
||||||
@ -13,22 +12,14 @@ final class HLSRecorder: ObservableObject {
|
|||||||
var onRecordingSaved: ((URL) -> Void)?
|
var onRecordingSaved: ((URL) -> Void)?
|
||||||
var onError: ((String) -> Void)?
|
var onError: ((String) -> Void)?
|
||||||
|
|
||||||
private var assetWriter: AVAssetWriter?
|
private var recordTask: Task<Void, Never>?
|
||||||
private var videoInput: AVAssetWriterInput?
|
|
||||||
private var audioInput: AVAssetWriterInput?
|
|
||||||
private var videoOutput: AVPlayerItemVideoOutput?
|
|
||||||
private var displayLink: CADisplayLink?
|
|
||||||
private var startTime: Date?
|
private var startTime: Date?
|
||||||
private var timer: Timer?
|
private var timer: Timer?
|
||||||
private var outputURL: URL?
|
|
||||||
private var firstPTS: CMTime?
|
|
||||||
private var audioTapID: MTAudioProcessingTapID?
|
|
||||||
|
|
||||||
func startRecording(url: URL, player: AVPlayer) {
|
func startRecording(url: URL, player: AVPlayer) {
|
||||||
guard !isRecording, let item = player.currentItem else { return }
|
guard !isRecording else { return }
|
||||||
isRecording = true
|
isRecording = true
|
||||||
startTime = Date()
|
startTime = Date()
|
||||||
firstPTS = nil
|
|
||||||
|
|
||||||
timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
|
timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
@ -38,116 +29,35 @@ final class HLSRecorder: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
recordTask = Task { [weak self] in
|
||||||
outputURL = docs.appendingPathComponent("MiniPlayer_Recording_\(Int(Date().timeIntervalSince1970)).mp4")
|
guard let self else { return }
|
||||||
|
|
||||||
Task { await startCapture(item: item, player: player) }
|
|
||||||
}
|
|
||||||
|
|
||||||
func stopRecording() {
|
|
||||||
guard isRecording else { return }
|
|
||||||
isRecording = false
|
|
||||||
timer?.invalidate()
|
|
||||||
displayLink?.invalidate()
|
|
||||||
videoOutput = nil
|
|
||||||
|
|
||||||
videoInput?.markAsFinished()
|
|
||||||
audioInput?.markAsFinished()
|
|
||||||
|
|
||||||
assetWriter?.finishWriting { [weak self] in
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
guard let self, let url = self.outputURL else { return }
|
|
||||||
let status = self.assetWriter?.status ?? .unknown
|
|
||||||
NSLog("[MiniPlayer] AVAssetWriter finished: %d", status.rawValue)
|
|
||||||
|
|
||||||
if status == .completed {
|
|
||||||
let attrs = try? FileManager.default.attributesOfItem(atPath: url.path)
|
|
||||||
let size = (attrs?[.size] as? Int64) ?? 0
|
|
||||||
NSLog("[MiniPlayer] Recorded: %lld bytes", size)
|
|
||||||
self.onRecordingSaved?(url)
|
|
||||||
} else {
|
|
||||||
let err = self.assetWriter?.error?.localizedDescription ?? "unknown"
|
|
||||||
self.onError?("Export: \(err)")
|
|
||||||
try? FileManager.default.removeItem(at: url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Capture setup
|
|
||||||
|
|
||||||
private func startCapture(item: AVPlayerItem, player: AVPlayer) async {
|
|
||||||
guard let outputURL else { return }
|
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let writer = try AVAssetWriter(url: outputURL, fileType: .mp4)
|
let outputURL: URL
|
||||||
|
if url.isFileURL {
|
||||||
// Natural size from asset
|
outputURL = try await self.copyLocalFile(url: url)
|
||||||
var size = CGSize(width: 1280, height: 720)
|
} else {
|
||||||
if let tracks = try? await item.asset.load(.tracks),
|
// 流媒体:当前 iOS 不支持直接录制 HLS 流
|
||||||
let videoTrack = tracks.first(where: { $0.mediaType == .video }) {
|
// CBS 等有 DRM/CDN 保护,需 ffmpeg iOS 静态库
|
||||||
let ns = try? await videoTrack.load(.naturalSize)
|
// 先用 URLSession 下载尝试(仅对无保护的 HTTP 流有效)
|
||||||
if let ns, ns.width > 0 { size = ns }
|
outputURL = try await self.downloadFile(url: url)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Video input
|
let attrs = try? FileManager.default.attributesOfItem(atPath: outputURL.path)
|
||||||
let videoSettings: [String: Any] = [
|
let size = (attrs?[.size] as? Int64) ?? 0
|
||||||
AVVideoCodecKey: AVVideoCodecType.h264,
|
NSLog("[MiniPlayer] Recording complete: %@ (%lld bytes)", outputURL.path, size)
|
||||||
AVVideoWidthKey: size.width,
|
|
||||||
AVVideoHeightKey: size.height,
|
await MainActor.run {
|
||||||
AVVideoCompressionPropertiesKey: [
|
self.isRecording = false
|
||||||
AVVideoAverageBitRateKey: 2_000_000,
|
self.timer?.invalidate()
|
||||||
AVVideoProfileLevelKey: AVVideoProfileLevelH264HighAutoLevel
|
if size > 1024 {
|
||||||
]
|
self.onRecordingSaved?(outputURL)
|
||||||
]
|
} else {
|
||||||
let vidInput = AVAssetWriterInput(mediaType: .video, outputSettings: videoSettings)
|
self.onError?("Recording too small (\(size) bytes) — stream may be DRM-protected. Try recording a local file.")
|
||||||
vidInput.expectsMediaDataInRealTime = true
|
try? FileManager.default.removeItem(at: outputURL)
|
||||||
guard writer.canAdd(vidInput) else {
|
|
||||||
throw NSError(domain: "Rec", code: 1, userInfo: [NSLocalizedDescriptionKey: "Cannot add video input"])
|
|
||||||
}
|
}
|
||||||
writer.add(vidInput)
|
|
||||||
self.videoInput = vidInput
|
|
||||||
|
|
||||||
// Audio input
|
|
||||||
let audioSettings: [String: Any] = [
|
|
||||||
AVFormatIDKey: kAudioFormatMPEG4AAC,
|
|
||||||
AVSampleRateKey: 44100,
|
|
||||||
AVNumberOfChannelsKey: 2,
|
|
||||||
AVEncoderBitRateKey: 128_000
|
|
||||||
]
|
|
||||||
let audInput = AVAssetWriterInput(mediaType: .audio, outputSettings: audioSettings)
|
|
||||||
audInput.expectsMediaDataInRealTime = true
|
|
||||||
guard writer.canAdd(audInput) else {
|
|
||||||
throw NSError(domain: "Rec", code: 2, userInfo: [NSLocalizedDescriptionKey: "Cannot add audio input"])
|
|
||||||
}
|
}
|
||||||
writer.add(audInput)
|
|
||||||
self.audioInput = audInput
|
|
||||||
|
|
||||||
// MTAudioProcessingTap on the player item's audio track
|
|
||||||
installAudioTap(on: item)
|
|
||||||
|
|
||||||
// Video output from player
|
|
||||||
let pixAttrs: [String: Any] = [
|
|
||||||
kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA,
|
|
||||||
kCVPixelBufferWidthKey as String: size.width,
|
|
||||||
kCVPixelBufferHeightKey as String: size.height
|
|
||||||
]
|
|
||||||
let vo = AVPlayerItemVideoOutput(pixelBufferAttributes: pixAttrs)
|
|
||||||
vo.suppressesPlayerRendering = false
|
|
||||||
item.add(vo)
|
|
||||||
self.videoOutput = vo
|
|
||||||
|
|
||||||
writer.startWriting()
|
|
||||||
// session start will be set on first frame
|
|
||||||
self.assetWriter = writer
|
|
||||||
|
|
||||||
displayLink = CADisplayLink(target: self, selector: #selector(captureFrame))
|
|
||||||
displayLink?.preferredFrameRateRange = CAFrameRateRange(minimum: 15, maximum: 30, preferred: 30)
|
|
||||||
displayLink?.add(to: .main, forMode: .common)
|
|
||||||
|
|
||||||
NSLog("[MiniPlayer] Capture started: %@", outputURL.path)
|
|
||||||
|
|
||||||
} catch {
|
} catch {
|
||||||
|
NSLog("[MiniPlayer] Recording error: %@", error.localizedDescription)
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
self.isRecording = false
|
self.isRecording = false
|
||||||
self.timer?.invalidate()
|
self.timer?.invalidate()
|
||||||
@ -155,151 +65,44 @@ final class HLSRecorder: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Audio tap
|
|
||||||
|
|
||||||
private func installAudioTap(on item: AVPlayerItem) {
|
|
||||||
guard let audioTrack = item.asset.tracks(withMediaType: .audio).first else {
|
|
||||||
NSLog("[MiniPlayer] No audio track found")
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var callbacks = MTAudioProcessingTapCallbacks(
|
func stopRecording() {
|
||||||
version: kMTAudioProcessingTapCallbacksVersion_0,
|
guard isRecording else { return }
|
||||||
clientInfo: Unmanaged.passUnretained(self).toOpaque(),
|
// 对于本地文件复制,立即完成;对于网络下载,取消任务
|
||||||
init: { tap, clientInfo, tapStorageOut in
|
recordTask?.cancel()
|
||||||
tapStorageOut?.pointee = clientInfo
|
isRecording = false
|
||||||
},
|
timer?.invalidate()
|
||||||
finalize: { _ in },
|
|
||||||
prepare: { _, _, _ in },
|
|
||||||
unprepare: { _, _, _ in },
|
|
||||||
process: { tap, numberFrames, flags, bufferListInOut, numberFramesOut, flagsOut in
|
|
||||||
guard let clientInfo = MTAudioProcessingTapGetStorage(tap) else {
|
|
||||||
numberFramesOut.pointee = 0
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let recorder = Unmanaged<HLSRecorder>.fromOpaque(clientInfo).takeUnretainedValue()
|
|
||||||
recorder.processAudio(tap: tap, numberFrames: numberFrames, flags: flags,
|
|
||||||
bufferListInOut: bufferListInOut,
|
|
||||||
numberFramesOut: numberFramesOut, flagsOut: flagsOut)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
var tap: Unmanaged<MTAudioProcessingTap>?
|
|
||||||
let status = MTAudioProcessingTapCreate(kCFAllocatorDefault, &callbacks,
|
|
||||||
kMTAudioProcessingTapCreationFlag_PreEffects, &tap)
|
|
||||||
guard status == noErr, let audioTap = tap?.takeRetainedValue() else {
|
|
||||||
NSLog("[MiniPlayer] MTAudioProcessingTapCreate failed: %d", status)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let inputParams = AVMutableAudioMixInputParameters(track: audioTrack)
|
// MARK: - 本地文件复制
|
||||||
inputParams.audioTapProcessor = audioTap
|
|
||||||
|
|
||||||
let audioMix = AVMutableAudioMix()
|
private func copyLocalFile(url: URL) async throws -> URL {
|
||||||
audioMix.inputParameters = [inputParams]
|
let ext = url.pathExtension.isEmpty ? "mp4" : url.pathExtension
|
||||||
item.audioMix = audioMix
|
let outputURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||||
|
.appendingPathComponent("MiniPlayer_Recording_\(Int(Date().timeIntervalSince1970)).\(ext)")
|
||||||
|
|
||||||
NSLog("[MiniPlayer] Audio tap installed")
|
if FileManager.default.fileExists(atPath: outputURL.path) {
|
||||||
|
try FileManager.default.removeItem(at: outputURL)
|
||||||
|
}
|
||||||
|
try FileManager.default.copyItem(at: url, to: outputURL)
|
||||||
|
return outputURL
|
||||||
}
|
}
|
||||||
|
|
||||||
private func processAudio(tap: MTAudioProcessingTap, numberFrames: CMItemCount, flags: MTAudioProcessingTapFlags,
|
// MARK: - 网络流下载(仅对无保护 HTTP 流有效)
|
||||||
bufferListInOut: UnsafeMutablePointer<AudioBufferList>,
|
|
||||||
numberFramesOut: UnsafeMutablePointer<CMItemCount>,
|
private func downloadFile(url: URL) async throws -> URL {
|
||||||
flagsOut: UnsafeMutablePointer<MTAudioProcessingTapFlags>) {
|
let ext = url.pathExtension.isEmpty ? "mp4" : url.pathExtension
|
||||||
// Get source audio
|
let outputURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||||
var sourceFlags = flags
|
.appendingPathComponent("MiniPlayer_Recording_\(Int(Date().timeIntervalSince1970)).\(ext)")
|
||||||
var timeRange = CMTimeRange()
|
|
||||||
let status = MTAudioProcessingTapGetSourceAudio(tap, numberFrames, bufferListInOut,
|
if FileManager.default.fileExists(atPath: outputURL.path) {
|
||||||
flagsOut, &timeRange, numberFramesOut)
|
try FileManager.default.removeItem(at: outputURL)
|
||||||
guard status == noErr, let input = audioInput, input.isReadyForMoreMediaData else {
|
|
||||||
flagsOut.pointee = flags
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create CMSampleBuffer from the audio buffer list
|
let (tempURL, _) = try await URLSession.shared.download(from: url)
|
||||||
let pts = timeRange.start
|
try FileManager.default.moveItem(at: tempURL, to: outputURL)
|
||||||
var asbd = AudioStreamBasicDescription()
|
return outputURL
|
||||||
MTAudioProcessingTapGetStreamDescription(tap, &asbd)
|
|
||||||
|
|
||||||
var formatDesc: CMAudioFormatDescription?
|
|
||||||
CMAudioFormatDescriptionCreate(allocator: kCFAllocatorDefault, asbd: &asbd,
|
|
||||||
layoutSize: 0, layout: nil, 0, nil, nil,
|
|
||||||
formatDescriptionOut: &formatDesc)
|
|
||||||
guard let fd = formatDesc else { return }
|
|
||||||
|
|
||||||
var sampleBuffer: CMSampleBuffer?
|
|
||||||
let numSamples = numberFramesOut.pointee
|
|
||||||
|
|
||||||
var timing = CMSampleTimingInfo(duration: CMTime(value: CMTimeValue(numSamples), timescale: CMTimeScale(asbd.mSampleRate)),
|
|
||||||
presentationTimeStamp: pts, decodeTimeStamp: .invalid)
|
|
||||||
|
|
||||||
CMSampleBufferCreate(allocator: kCFAllocatorDefault, dataBuffer: nil, dataReady: false,
|
|
||||||
makeDataReadyCallback: nil, refcon: nil, formatDescription: fd,
|
|
||||||
sampleCount: numSamples, sampleTimingEntryCount: 1,
|
|
||||||
sampleTimingArray: &timing, sampleSizeEntryCount: 0,
|
|
||||||
sampleSizeArray: nil, sampleBufferOut: &sampleBuffer)
|
|
||||||
|
|
||||||
guard var sb = sampleBuffer else { return }
|
|
||||||
|
|
||||||
// Copy audio data into the sample buffer
|
|
||||||
let bufList = bufferListInOut.pointee
|
|
||||||
var blockBuffer: CMBlockBuffer?
|
|
||||||
CMBlockBufferCreateWithMemoryBlock(
|
|
||||||
allocator: kCFAllocatorDefault,
|
|
||||||
memoryBlock: nil,
|
|
||||||
blockLength: Int(bufList.mBuffers.mDataByteSize),
|
|
||||||
blockAllocator: kCFAllocatorDefault,
|
|
||||||
customBlockSource: nil,
|
|
||||||
offsetToData: 0,
|
|
||||||
dataLength: Int(bufList.mBuffers.mDataByteSize),
|
|
||||||
flags: 0,
|
|
||||||
blockBufferOut: &blockBuffer
|
|
||||||
)
|
|
||||||
if let bb = blockBuffer {
|
|
||||||
CMBlockBufferReplaceDataBytes(with: bufList.mBuffers.mData!, blockBuffer: bb,
|
|
||||||
offsetIntoDestination: 0, dataLength: Int(bufList.mBuffers.mDataByteSize))
|
|
||||||
CMSampleBufferSetDataBuffer(&sb, newValue: bb)
|
|
||||||
input.append(sb)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Video frame capture
|
|
||||||
|
|
||||||
@objc private func captureFrame() {
|
|
||||||
guard let writer = assetWriter, let vo = videoOutput, let vidInput = videoInput else { return }
|
|
||||||
|
|
||||||
let itemTime = vo.itemTime(forHostTime: CACurrentMediaTime())
|
|
||||||
guard vo.hasNewPixelBuffer(forItemTime: itemTime),
|
|
||||||
let buf = vo.copyPixelBuffer(forItemTime: itemTime, itemTimeForDisplay: nil)
|
|
||||||
else { return }
|
|
||||||
|
|
||||||
// Start session on first frame
|
|
||||||
if writer.status == .unknown {
|
|
||||||
writer.startSession(atSourceTime: itemTime)
|
|
||||||
firstPTS = itemTime
|
|
||||||
NSLog("[MiniPlayer] Session started at PTS: %.3f", itemTime.seconds)
|
|
||||||
}
|
|
||||||
|
|
||||||
guard vidInput.isReadyForMoreMediaData else { return }
|
|
||||||
|
|
||||||
var timing = CMSampleTimingInfo(
|
|
||||||
duration: CMTime(value: 1, timescale: 30),
|
|
||||||
presentationTimeStamp: itemTime,
|
|
||||||
decodeTimeStamp: .invalid
|
|
||||||
)
|
|
||||||
var formatDesc: CMFormatDescription?
|
|
||||||
CMVideoFormatDescriptionCreateForImageBuffer(allocator: kCFAllocatorDefault,
|
|
||||||
imageBuffer: buf, formatDescriptionOut: &formatDesc)
|
|
||||||
guard let fd = formatDesc else { return }
|
|
||||||
|
|
||||||
var sampleBuffer: CMSampleBuffer?
|
|
||||||
CMSampleBufferCreateReadyWithImageBuffer(allocator: kCFAllocatorDefault, imageBuffer: buf,
|
|
||||||
formatDescription: fd, sampleTiming: &timing,
|
|
||||||
sampleBufferOut: &sampleBuffer)
|
|
||||||
if let sb = sampleBuffer {
|
|
||||||
vidInput.append(sb)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@ -8,6 +8,19 @@
|
|||||||
<string>MiniPlayer</string>
|
<string>MiniPlayer</string>
|
||||||
<key>CFBundleExecutable</key>
|
<key>CFBundleExecutable</key>
|
||||||
<string>$(EXECUTABLE_NAME)</string>
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
|
<key>CFBundleIcons</key>
|
||||||
|
<dict>
|
||||||
|
<key>CFBundlePrimaryIcon</key>
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleIconFiles</key>
|
||||||
|
<array>
|
||||||
|
<string>AppIcon60x60</string>
|
||||||
|
<string>AppIcon60x60</string>
|
||||||
|
</array>
|
||||||
|
<key>CFBundleIconName</key>
|
||||||
|
<string>AppIcon</string>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
<key>CFBundleIdentifier</key>
|
<key>CFBundleIdentifier</key>
|
||||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
<key>CFBundleInfoDictionaryVersion</key>
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
@ -22,6 +35,15 @@
|
|||||||
<string>1</string>
|
<string>1</string>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>NSAppTransportSecurity</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSAllowsArbitraryLoads</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
<key>NSPhotoLibraryAddUsageDescription</key>
|
||||||
|
<string>Save recorded videos to your photo library</string>
|
||||||
|
<key>NSPhotoLibraryUsageDescription</key>
|
||||||
|
<string>Access photo library to save recordings</string>
|
||||||
<key>UIBackgroundModes</key>
|
<key>UIBackgroundModes</key>
|
||||||
<array>
|
<array>
|
||||||
<string>audio</string>
|
<string>audio</string>
|
||||||
@ -41,26 +63,5 @@
|
|||||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
</array>
|
</array>
|
||||||
<key>NSAppTransportSecurity</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSAllowsArbitraryLoads</key>
|
|
||||||
<true/>
|
|
||||||
</dict>
|
|
||||||
<key>NSPhotoLibraryAddUsageDescription</key>
|
|
||||||
<string>Save recorded videos to your photo library</string>
|
|
||||||
<key>NSPhotoLibraryUsageDescription</key>
|
|
||||||
<string>Access photo library to save recordings</string>
|
|
||||||
<key>CFBundleIcons</key>
|
|
||||||
<dict>
|
|
||||||
<key>CFBundlePrimaryIcon</key>
|
|
||||||
<dict>
|
|
||||||
<key>CFBundleIconFiles</key>
|
|
||||||
<array>
|
|
||||||
<string>AppIcon60x60</string>
|
|
||||||
</array>
|
|
||||||
<key>CFBundleIconName</key>
|
|
||||||
<string>AppIcon</string>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user