[Swift] 68 メモアプリ製作 その20 TestFlight移行時のトラブル CoreData / CloudKit Console

[Mac M2 Pro 12CPU, Ventura 13.6.1, iOS 17.2.1, Xcode 15.0]

・現況

2週間ぶりの投稿になります。

visionOS用アプリ・リリースの予行演習として、メモアプリのApp Store登録に没頭していました。

メモアプリ界隈はレッドオーシャンですから収益云々については全く期待していません。そもそもApp StoreアプリiOS版の貧弱な検索機能では私のアプリにたどり着けないでしょう。

・問題発覚

話を戻しますが、デバッグ版がほぼ完成したところで、リリース版を検証するためTestFlightに移行しました。移行当初はSANDBOXを使ったアプリ内課金やサブスクの検証しかしていなかったのですが、しばらくしてiCloudに配置しているCoreDataが使えなくなっていることに気が付きました。

Distribution証明書やProvisioning Profileの問題かと考え、何度も作成しなおしてはXcodeでArchiveをアップロードしていたところ、1日20回のアップロード制限に引っかかってしまいました。踏んだり蹴ったりとはこのことです。

仕方ないので捨てアプリ名でApp Store Connectに登録し直し*、色々試してみたものの解決には至らず1日超でギブアップしました。

・ようやく解決

しばらく頭を冷やしてから何となく検索語”cloudkit coredata release”でGoogle検索したところ、毎度お馴染みStackOverFlowの記事がヒットしました。

CloudKit ConsoleのCloudKit DatabaseでDeploy Schema Changesボタンを押し、次の画面でDeployボタンを押すと開発環境のCloudKitをリリース版で使えるようになる、とのことでした。AppleのCloudKit関連ドキュメントに説明があります。

CloudKitの扱いは慎重に行うべきということでしょう。なお1日後に解除とされていたArchiveのアップロード制限ですが、実際は13時間後解除でした。

結局、問題発覚から解決まで1日半掛かってしまいました。

・感想

開発者・配布者証明書を発行する際に使うキーチェーンアクセスや、Provisioning Profileなどを扱う”Certificates, Identifiers & Profiles”を散々いじり倒して大分使いこなせるようになってきましたし、CloudKitやCoreDataについて理解を深めることができたのは収穫でした。

もちろんChatGPTも駆使しましたが、回答の中でCloudKit Consoleの設定について触れることは1ミリもなかったです。プロンプトに問題がなかったか、時間があれば検証します。まあ雑な質問でもこれ位は答えて欲しいところではあります。

StackOverFlow記事

※ App Store Connectに登録したアプリは削除可能ですが、Bundle IdentifierはAppleサポートに削除してもらわないと再使用できないようです。App Store Connectには安易に登録しないのが無難でしょう。

[Swift] 67 メモアプリ製作 その19 キーボード表示時のウィジェット移動対策

[Mac M2 Pro 12CPU, Ventura 13.6.1, iOS 17.1.2, Xcode 15.0]

アプリへの入力時に画面底部にあるボタンがキーボードによって上に押し上げられてしまう現象への対策です。

キーボード表示時にNotificationCenterから通知を受け取り、Bool値が変わると以下のモディファイアが動作するようにしました。

ボタン使用不可
.disabled(showingKeyboard)
ボタン非表示
.opacity(showingKeyboard ? 0.0 : 1.0)

キーボードの高さを取得してpaddingモディファイアでbottomからオフセットする方法も試しましたが、上手く出来ませんでした。

キーボードがない状態
キーボードが出現すると”バックアップ確認”などが上に押し上げられる
キーボード表示時にボタンを非表示および使用不可にした
@State private var showingKeyboard = false

Button("backupCheck".localized) { // バックアップ確認
    showingBackupView = true
}
.sheet(isPresented: $showingBackupView) {
    BackupView().environment(\.managedObjectContext, self.viewContext)
}
.position(x: geometry.size.width * 0.48, y: geometry.size.height * 0.98)
.frame(width: 128, height: 36)
.disabled(showingKeyboard)
.opacity(showingKeyboard ? 0.0 : 1.0)
.onAppear {
    // キーボードの表示・非表示の通知を購読
    NotificationCenter.default.addObserver(forName: UIResponder.keyboardWillShowNotification, object: nil, queue: .main) { _ in
        showingKeyboard = true
    }
    NotificationCenter.default.addObserver(forName: UIResponder.keyboardWillHideNotification, object: nil, queue: .main) { _ in
        showingKeyboard = false
    }
}
.onDisappear {
    // 通知の購読を解除
    NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardWillShowNotification, object: nil)
    NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardWillHideNotification, object: nil)
}

[Swift] 66 メモアプリ製作 その18 データをエクスポート  info.plist : Application supports iTunes file sharing

[Mac M2 Pro 12CPU, Ventura 13.6.1, iOS 17.1.2, Xcode 15.0]

暗号化の有無切り替えはデータを初期化しないと出来ない仕様にしています。安易に切り替えないための措置です。暗号化解除によりセキュリティは低下しますが、パフォーマンスは向上します。

メモデータを保存したい方のためにCSVエクスポート機能を追加しました。

この機能の実装にはinfo.plistのキーを2つ追加する必要があります。これが分からなくてしばらく右往左往しました。
・Application supports iTunes file sharing : YES
・Supports opening documents in place : YES

暗号化有無の切り替えに伴うCore Data自動変換やCSVインポート機能は実装できなくはないですが、サポート範囲拡大による開発負担増大を避けるため、あえて実装しないことにしました。

iPhoneのファイルアプリで確認できる
func exportData() {
        // CSVデータのヘッダを設定
        var csvString = "Content\n"
        
        for note in contents {
            // Noteのcontentを取得し、CSV形式に整形
            if let content0 = note.content, let decryptedContent = CryptoManager.shared.decrypt(content0) {
                // 特殊文字をエスケープする処理を追加
                let escapedContent = decryptedContent.replacingOccurrences(of: "\"", with: "\"\"")
                // CSV形式の文字列に追加(コンマや改行を含むcontentはダブルクォートで囲む)
                csvString.append("\"\(escapedContent)\"\n")
            }
        }
        
        let fileManager = FileManager.default
        #if os(iOS)
        let docPath = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first!
        let fileURL = docPath.appendingPathComponent("cloudMemo.csv")
        #elseif os(macOS)
        let homeDirectory = fileManager.homeDirectoryForCurrentUser
        let fileURL = homeDirectory.appendingPathComponent("cloudMemo.csv")
        #else
        return
        #endif
        
        do {
            // CSVデータをファイルに書き込む
            try csvString.write(to: fileURL, atomically: true, encoding: .utf8)
            print("CSVファイルのエクスポートが成功しました。ファイルパス: \(fileURL.path)")
        } catch {
            print("CSVファイルの書き込みに失敗しました: \(error)")
        }
    }

[Swift] 65 メモアプリ製作 その17 データ暗号化 クロスデバイス対応 / iCloudキーチェーン

[Mac M2 Pro 12CPU, Ventura 13.6.1, iOS 17.1.2, Xcode 15.0]

前回の記事は暗号化キーを通常のキーチェーンに保管するという内容でした。

このアプリではiPadOS版やwatchOS版とも共有するため、iCloudキーチェーンに保管するようにしました。

Xcodeのプロジェクト設定でCapabilityとしてKeychain Sharingを追加し、アプリのBundle Identifierを登録する必要があります。

コードではKeychainManagerクラスにkSecAttrSynchronizableを追加します。

class KeychainManager {
    static func save(key: String, data: Data) -> OSStatus {
        let query = [
            kSecClass as String             : kSecClassGenericPassword as String,
            kSecAttrAccount as String       : key,
            kSecValueData as String         : data,
            kSecAttrSynchronizable as String: kCFBooleanTrue! ] as [String : Any] // この行を追加

        SecItemDelete(query as CFDictionary)
        return SecItemAdd(query as CFDictionary, nil)
    }
    
    static func load(key: String) -> Data? {
        let query = [
            kSecClass as String             : kSecClassGenericPassword,
            kSecAttrAccount as String       : key,
            kSecReturnData as String        : kCFBooleanTrue!,
            kSecMatchLimit as String        : kSecMatchLimitOne,
            kSecAttrSynchronizable as String: kCFBooleanTrue! ] as [String : Any] // この行を追加

        var item: CFTypeRef?
        let status = SecItemCopyMatching(query as CFDictionary, &item)
        if status == noErr {
            return item as? Data
        }
        return nil
    }
}

[Swift] 64 メモアプリ製作 その16 データ暗号化実装 CryptoKit, キーチェーン

[Mac M2 Pro 12CPU, Ventura 13.6.1, iOS 17.1.2, Xcode 15.0]

データを暗号化してからCore Dataに保存するようにしました。暗号化キーはKeyChainに保存しています。

UIのコーディングでは毎度苦労させられますが、暗号化については情報は簡単に入手できて実装は比較的容易でした。

import CryptoKit
import Security
import Foundation

class CryptoManager {
    static let shared = CryptoManager()
    private let key: SymmetricKey
    
    private init() {
        if let keyData = KeychainManager.load(key: "myEncryptionKey") {
            // Keychainからキーを読み込む
            key = SymmetricKey(data: keyData)
        } else {
            // 新しいキーを生成してKeychainに保存する
            let newKey = SymmetricKey(size: .bits256)
            let newKeyData = newKey.withUnsafeBytes { Data($0) }
            let status = KeychainManager.save(key: "myEncryptionKey", data: newKeyData)
            if status == noErr {
                print("新しいキーをKeychainに保存しました。")
                key = newKey
            } else {
                fatalError("キーの保存に失敗しました。")
            }
        }
    }
    
    func encrypt(_ text: String) -> String? {
        guard let data = text.data(using: .utf8) else { return nil }
        do {
            let sealedBox = try AES.GCM.seal(data, using: key)
            return sealedBox.combined?.base64EncodedString()
        } catch {
            print("Encryption error: \(error)")
            return nil
        }
    }

    func decrypt(_ encryptedText: String) -> String? {
        guard let data = Data(base64Encoded: encryptedText),
              let sealedBox = try? AES.GCM.SealedBox(combined: data) else { return nil }
        do {
            let decryptedData = try AES.GCM.open(sealedBox, using: key)
            return String(data: decryptedData, encoding: .utf8)
        } catch {
            print("Decryption error: \(error)")
            return nil
        }
    }

}

class KeychainManager {
    static func save(key: String, data: Data) -> OSStatus {
        let query = [
            kSecClass as String       : kSecClassGenericPassword as String,
            kSecAttrAccount as String : key,
            kSecValueData as String   : data ] as [String : Any]

        SecItemDelete(query as CFDictionary) // 既存のアイテムを削除
        return SecItemAdd(query as CFDictionary, nil)
    }
    
    static func load(key: String) -> Data? {
        let query = [
            kSecClass as String       : kSecClassGenericPassword,
            kSecAttrAccount as String : key,
            kSecReturnData as String  : kCFBooleanTrue!,
            kSecMatchLimit as String  : kSecMatchLimitOne ] as [String : Any]

        var item: CFTypeRef?
        let status = SecItemCopyMatching(query as CFDictionary, &item)
        if status == noErr {
            return item as? Data
        }
        return nil
    }
}

[Swift] 63 メモアプリ製作 その15 バックアップから復元機能実装

[Mac M2 Pro 12CPU, Ventura 13.6, iOS 17.1.2, Xcode 15.0]

バックアップ用テキストエディタ構造体 Draft2を作成し、失ったメモをクリップボードにコピーできるようにしていましたが、よりスムーズにするため復元ボタンを配置してワンタップで復元できるようにしました。

これでApple Watchでも復元したメモを見ることができます。

import SwiftUI

struct Draft2: View {
    @State var text = ""
    @FocusState var nameFieldIsFocused: Bool
    @Environment(\.managedObjectContext)var viewContext

    var body: some View {
        TextEditor(text:$text)
            .frame(minHeight: 0, maxHeight: .infinity)
            .focused($nameFieldIsFocused)
            .onAppear{
                DispatchQueue.main.asyncAfter(deadline:DispatchTime.now()+0.5){
                    nameFieldIsFocused = true
                }
            }
            .toolbar{
                ToolbarItem(placement:.navigationBarTrailing){
                    if(nameFieldIsFocused){
                        Button(action:{
                            addContent()
                        }){
                            Text("復元")
                            .font(.system(size: 20))
                        }
                    }
                }
            }
    }
    
    func addContent(){
        let newContent = Note(context:viewContext)

        let date = Date()
        newContent.creationDate = date
        newContent.content = text

        // Data Modelを更新
        viewContext.refreshAllObjects()
        
        do{
            try viewContext.save()
            
        }catch{
            fatalError("セーブ失敗")
        }
        nameFieldIsFocused = false
    }
}

[Swift] 62 メモアプリ製作 その14 バックアップ機能実装

[Mac M2 Pro 12CPU, Ventura 13.6, iOS 17.1.2, Xcode 15.0]

Apple Watchで誤ってメモを消してしまうという事故が数ヶ月内で2回発生しました。

対策としてバックアップ機能を新たに実装しました。

メモの保存時にバックアップ用Core Dataにも同じデータを追加するという仕様です。誤って消してしまってもiPhoneの方でバックアップを確認できます。

import SwiftUI

struct Draft: View {
    @State var text = ""
    @FocusState var nameFieldIsFocused: Bool
    @Environment(\.managedObjectContext)var viewContext
    var note: Note?
    var noteBackup: NoteBackup?

    var body: some View {
        TextEditor(text:$text)
            .frame(minHeight: 0, maxHeight: .infinity)
            .focused($nameFieldIsFocused)
            .onAppear{
                DispatchQueue.main.asyncAfter(deadline:DispatchTime.now()+0.5){
                    nameFieldIsFocused = true
                }
            }
            .toolbar{
                ToolbarItem(placement:.navigationBarTrailing){
                    if(nameFieldIsFocused){
                        Button(action:{
                            if note != nil {
                                updateContent(note: self.note!)
                            }else{
                                addContent()
                            }

                        }){
                            Text("更新")
                            .font(.system(size: 20))
                        }
                    }
                }
            }
    }

    func updateContent(note:Note){
        let date = Date()
        note.creationDate = date
        note.content=text
        
        let newContent2 = NoteBackup(context:viewContext)
        newContent2.creationDate = date
        newContent2.content = text

        // Data Modelを更新
        viewContext.refreshAllObjects()
        
        do{
            try viewContext.save()
        }catch{
            fatalError("セーブ失敗")
        }

        nameFieldIsFocused = false
    }

    func addContent(){
        let newContent = Note(context:viewContext)
        let newContent2 = NoteBackup(context:viewContext)

        let date = Date()
        newContent.creationDate = date
        newContent.content = text
        
        newContent2.creationDate = date
        newContent2.content = text

        // Data Modelを更新
        viewContext.refreshAllObjects()
        
        do{
            try viewContext.save()
            
        }catch{
            fatalError("セーブ失敗")
        }

        nameFieldIsFocused = false

    }

}

[Swift] 61 visionOSアプリ製作 その9 3Dモデルのカラー変更機能実装 PhysicallyBasedMaterial.BaseColor

[Mac M2 Pro 12CPU, Ventura 13.6, visionOS 1.0 (21N5300a), Xcode 15.1 beta 3]

ランチャーアプリvisionOS版を開発しています。

3Dモデルの各面および面取り領域のカラーを自由に変更できるようにしました。

予定の機能は全て実装完了となり、これで一区切りとします。

[Swift] 60 visionOSアプリ製作 その8 ModelEntityのカラー変更 RealityKit

[Mac M2 Pro 12CPU, Ventura 13.6, visionOS 1.0 (21N5300a), Xcode 15.1 beta 3]

usdzファイルを読み込んだModelEntityは後からコードでカラー変更できるようです。あくまでアプリ上で一時的に変わるだけで、元モデルの色は変わりません。

マテリアルのインデックス番号を指定して、3Dモデルの各面や面取り領域のカラーを変えることも可能でしょう。

Reality Composer Proの機能をコード化しているといったところでしょうか。

var body: some View {
    ZStack {
        RealityView { content in
            if let entity = try? await ModelEntity(named: "hexa") {
                // エンティティのモデルを取得
                if var modelComponent = entity.model {
                    for i in 0..<modelComponent.materials.count {
                        if var material = modelComponent.materials[i] as? PhysicallyBasedMaterial {
                            // 全てのマテリアルをグリーンにする
                            material.baseColor = PhysicallyBasedMaterial.BaseColor(tint: .green)
                            // マテリアルを更新
                            modelComponent.materials[i] = material
                        }
                    }
                    // エンティティのモデルコンポーネントを更新
                    entity.model = modelComponent
                }

[Swift] 59 visionOSアプリ製作 その7 スライダー、SwiftData、Componentの連携

[Mac M2 Pro 12CPU, Ventura 13.6, visionOS 1.0 (21N5300a), Xcode 15.1 beta 3]

スライダーで3Dモデルの回転速度を変えられるようにしました。

スライダーを動かして止めると同時に変数、SwiftData、Componentの値を更新する仕組みの構築に苦労しました。

回転速度を設定しても他の画面に遷移して設定画面に戻るとスライダーが初期値になっている(回転速度自体はそのまま)、スライダーが初期値から動かない(動かしても離すと戻る)など様々なトラブルに見舞われ、結局丸一日掛かってしまいました。