[C++] 357 ChatGPTアプリの製作 その42 “libcurlによるHTTP通信”の中断 curl_easy_cleanup 

[Mac M2 Pro 12CPU, Sonoma 14.3.1, clang++ 15.0.0, FLTK 1.3.8]

中断ボタンでlibcurlによるHTTP通信をabortしようとしましたが、かなり手こずりました。

C++では難しいためSwiftへの移植も考えたものの、相当な時間を掛けることになるのでもう一粘りしたところ、あっさり解決しました。

CURL* curl = curl_easy_init();のcurlをグローバル変数にして、中断コールバック関数内でcurl_easy_cleanup(curl);とすれば中断できるようになりました。

シングルスレッドであるFLTKにて送信ボタン押下状態で中断ボタンを押せるようにするにはstd::threadを用い送信コールバック関数(HTTP通信)を別スレッド化する必要があります。

最初は別スレッド化したHTTP通信のプロセスID(PID)をpgrepコマンドで探し、killコマンドで中断しようとしました。しかしstd::threadでは同じプロセスIDをメインスレッドと共用しているため、その方法は使えませんでした。

昨年2023/3/2にChatGPTアプリ開発に着手してから、ずっと抱えていた懸案をようやく解決できました。

libcurlでHTTP通信が容易に中断できない問題はStackOverFlowサイトでも暗礁に乗り上げていたので意外な結末でした。ChatGPTに聞いてもサンプルコードが少ないためか、珍しく迷回答頻発でした。

#include <thread>
#include <future>
#include <curl/curl.h>

extern CURL* curl;

void sendCBWrapper(Fl_Widget* w, void* v) {
    // promiseとfutureを作成
    std::promise<void> p;
    std::future<void> f = p.get_future();
    
    // sendCBを新しいスレッドで実行し、promiseを渡す
    std::thread([w, v, p = std::move(p)]() mutable {
        sendCB(w, v, std::move(p));
    }).detach();
    
    // 別のスレッドでfutureの結果を待機
    std::thread([f = std::move(f)]() mutable {
        f.wait(); // sendCBの終了を待機
        std::system("afplay /System/Library/Sounds/Submarine.aiff"); // システム音を鳴らす
        cout << "sendCB終了" << endl;
    }).detach();
}

void abortCB(Fl_Widget*, void*){
    if (curl){
        curl_easy_cleanup(curl);
        statusBox -> changeColor(YELLOW);
    } else {
        cout << "送信していません" << endl;
    }
}

[C++] 356 ChatGPTアプリの製作 その41 FLTKマルチスレッド化 promise, future

[Mac M2 Pro 12CPU, Sonoma 14.3.1, clang++ 15.0.0, FLTK 1.3.8]

ChatGPTアプリのアップデートは久々です。

cURL通信を中断する機能を実装するため、マルチスレッド対応に着手しました。

まずはリクエストの送信を別スレッドにしてデタッチ(切り離し)するようにしました。

これまでは受信があるまでGUIが固まっていたのが、他のボタンを押したりできるようになります。

ただし別スレッドの動作が終わってもその信号をGUIが受け取ることはできないため、システム音で知らせるようにしました。システム音が聞こえると自分でカーソルを少し動かして、メインスレッドに戻します。メインスレッドに戻るとリクエストの結果がGUIに表示されます。

次は中断ボタンを押すとコールバック関数で受信をabortするようにします。

#include <thread>
#include <future>

void sendCB(Fl_Widget*, void* data, std::promise<void>&& p) {

    <中略>

    p.set_value();
}

void sendCBWrapper(Fl_Widget* w, void* v) {
    // promiseとfutureを作成
    std::promise<void> p;
    std::future<void> f = p.get_future();
    
    // sendCBを新しいスレッドで実行し、promiseを渡す
    std::thread([w, v, p = std::move(p)]() mutable {
        sendCB(w, v, std::move(p));
    }).detach();
    
    // 別のスレッドでfutureの結果を待機
    std::thread([f = std::move(f)]() mutable {
        f.wait(); // sendCBの終了を待機
        std::system("afplay /System/Library/Sounds/Submarine.aiff"); // システム音を鳴らす
        cout << "sendCB終了" << endl;
    }).detach();
}

[Swift] 70 Geminiアプリ製作検討 Google AI SDK for Swift

[Mac M2 Pro 12CPU, Sonoma 14.3.1, Xcode 15.2]

Googleの生成AIモデル Bardの後継モデル Geminiの実力を検証しました。今回はプログラミング補助としての評価です。

結果は散々でした。GPT-4 TurboどころかGPT-3.5の足元にも及ばない、という評価です。

料金が安いので期待していたのですが、とても残念です。あのGoogleですから、それなりのものを出してくると思っていました。

2024年2月時点では、GPT(OpenAI) >>> LLama(Meta)、圏外 Gemini(Google)といったところでしょうか。Geminiは比較以前の問題かと。

OpenAIの天下は当分続きそうです。Appleは完全に周回遅れですから、業務提携か買収しか道はないのでは。

gemini-proを使ったが、回答は0点。これでは厳しい。
GPT-4 Turbo:短いプロンプトでも意図を汲み取って完璧な回答

[Swift] 69 watchOS用ColorPicker作成

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

久々の投稿です。

watchOS用に25色ColorPickerを作成しました。

import SwiftUI

struct ColorPickerAW: View {
    @Binding var selectedColor: Color
    var onColorSelected: (() -> Void)?

    // カラーコードをColorに変換する関数
    func colorFromHex(_ hex: String) -> Color {
        var hexSanitized = hex.trimmingCharacters(in: .whitespacesAndNewlines)
        hexSanitized = hexSanitized.replacingOccurrences(of: "#", with: "")
        
        var rgb: UInt64 = 0
        
        Scanner(string: hexSanitized).scanHexInt64(&rgb)
        
        let red = Double((rgb & 0xFF0000) >> 16) / 255.0
        let green = Double((rgb & 0x00FF00) >> 8) / 255.0
        let blue = Double(rgb & 0x0000FF) / 255.0
        
        return Color(red: red, green: green, blue: blue)
    }
    
    // カラーコードの配列
    let colors: [String] = [
        "#FFFFFF", "#C0C0C0", "#808080", "#000000", "#000080",
        "#0000FF", "#00FFFF", "#40E0D0", "#008080", "#808000",
        "#008000", "#00FF00", "#F5F5DC", "#FFFF00", "#FFD700",
        "#FFA500", "#FF7F50", "#FF0000", "#800000", "#A52A2A",
        "#DDA0DD", "#E6E6FA", "#FFC0CB", "#FF00FF", "#800080"
    ]
    
    var body: some View {
        // グリッド表示
        LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 5), spacing: 10) {
            ForEach(colors, id: \.self) { colorCode in
                Rectangle()
                    .fill(colorFromHex(colorCode))
                    .frame(width: 25, height: 25) // 長方形のサイズ
                    .onTapGesture {
                        // タップされた色を選択
                        self.selectedColor = colorFromHex(colorCode)
                        // 親ビューに通知
                        self.onColorSelected?()
                    }
            }
        }
        .padding()
    }
}
VStack(){
    Toggle("Show_Era".localized, isOn: $eraName)
        .frame(width: 140, height: 80, alignment: .center)
        .foregroundColor(.blue)
        .font(.system(size: 24))
        .onChange(of: eraName) { newValue in
            UserDefaults.standard.set(newValue, forKey: eraNameKey)
            print("eraNameが\(newValue)に変更され、UserDefaultsに保存されました。")
        }
    HStack(spacing:2){
        Text("Watch\nRow1")
        Rectangle()
            .fill(colorWatch1)
            .frame(width: 30, height: 30)
            .opacity(1.0)
        Button("Set") {
            showingColorPicker = true
        }
    }
    
    Spacer()
}
.sheet(isPresented: $showingColorPicker) {
    ColorPickerAW(selectedColor: $selectedColor) {
        showingColorPicker = false
        colorWatch1 = selectedColor
    }
}

[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
    }
}