SwiftUIでデバッグログをテキストで出力させる

# Macに接続せずに実機でテストする場合

今回はviewごとのページ内で定期的に実行される処理をiosで実行してる時に上手く動かないことがあり、それを調査するためのテキストを出力する専用のクラスを作成しました。

プログラムによっては、実機に接読しながらだと失敗してしまう処理（Googleログインなどは失敗することが多いです）があるのでそれ用に作成したものとなります。

ドキュメント直下から `/アプリ名/DebugLogs/app_debug_log.txt` に作成されます

#### DebugLogger.swift
```
import Foundation

class DebugLogger {
    static let shared = DebugLogger()
    private let fileManager = FileManager.default
    private let logFolder = "DebugLogs"
    private let logFileName = "app_debug_log.txt"

    private init() {
        createLogFileIfNeeded()
    }

    /// 📂 Documentsフォルダのパスを取得
    private func getDocumentsDirectory() -> URL? {
        return fileManager.urls(for: .documentDirectory, in: .userDomainMask).first
    }

    /// 🌟 **ログフォルダのパスを取得（外部用）**
    func getLogFolderPath() -> URL? {
        return getLogFolder()
    }
    
    /// 📂 DebugLogsフォルダのパスを取得 or 作成
    func getLogFolder() -> URL? {
        guard let documentsPath = getDocumentsDirectory() else { return nil }
        let logFolderPath = documentsPath.appendingPathComponent(logFolder)

        if !fileManager.fileExists(atPath: logFolderPath.path) {
            do {
                try fileManager.createDirectory(at: logFolderPath, withIntermediateDirectories: true)
            } catch {
                print("❌ ログフォルダの作成に失敗: \(error)")
                return nil
            }
        }
        return logFolderPath
    }

    /// 📝 ログファイルのパスを取得
    private func getLogFilePath() -> URL? {
        guard let logFolderPath = getLogFolder() else { return nil }
        return logFolderPath.appendingPathComponent(logFileName)
    }

    /// 📝 ログファイルを作成（存在しない場合）
    private func createLogFileIfNeeded() {
        guard let logFilePath = getLogFilePath() else { return }
        
        if !fileManager.fileExists(atPath: logFilePath.path) {
            do {
                try "".write(to: logFilePath, atomically: true, encoding: .utf8)
                print("✅ ログファイルを作成: \(logFilePath)")
            } catch {
                print("❌ ログファイルの作成に失敗: \(error)")
            }
        }
    }

    /// 📝 ログを追加（日時つき、追記形式）
    func log(_ message: String, level: String = "INFO") {
        guard let logFilePath = getLogFilePath() else { return }
        
        let timestamp = getCurrentTimestamp()
        let logMessage = "\(timestamp) [\(level)] \(message)\n"

        if let data = logMessage.data(using: .utf8) {
            if fileManager.fileExists(atPath: logFilePath.path) {
                // 追記モードで書き込む
                do {
                    let fileHandle = try FileHandle(forWritingTo: logFilePath)
                    fileHandle.seekToEndOfFile()
                    fileHandle.write(data)
                    fileHandle.closeFile()
                } catch {
                    print("❌ ログの書き込みに失敗: \(error)")
                }
            } else {
                // 新規作成
                do {
                    try data.write(to: logFilePath)
                } catch {
                    print("❌ 新しいログファイルの作成に失敗: \(error)")
                }
            }
        }
    }

    /// 📜 ログファイルの内容を取得
    func fetchLogs() -> String? {
        guard let logFilePath = getLogFilePath() else { return nil }

        do {
            return try String(contentsOf: logFilePath, encoding: .utf8)
        } catch {
            print("❌ ログの取得に失敗: \(error)")
            return nil
        }
    }

    /// 🗑️ ログファイルを削除
    func clearLogs() {
        guard let logFilePath = getLogFilePath() else { return }

        do {
            try fileManager.removeItem(at: logFilePath)
            createLogFileIfNeeded() // 再作成
            print("🗑️ ログファイルを削除しました")
        } catch {
            print("❌ ログの削除に失敗: \(error)")
        }
    }

    /// ⏰ 現在のタイムスタンプを取得（YYYY年_MM月DD日 HH:mm:ss の形式）
    private func getCurrentTimestamp() -> String {
        let formatter = DateFormatter()
        formatter.dateFormat = "yyyy年_MM月dd日 HH:mm:ss"
        formatter.locale = Locale(identifier: "ja_JP") // 日本のロケールを設定
        return formatter.string(from: Date())
    }
}

```

# 使用例
今回は WebViewを内包した`WebViewContainer`が定期的に座標の判定をするというものに使用したコードになります。

# 使用部分
```
DebugLogger.shared.log("🟢 出力例", level: "INFO")
```

# 出力例

#### app_debug_log.txt
```
20XX年_XX月XX日 13:23:29 [INFO] ✅ JavaScript によるクッキー設定成功
20XX年_XX月XX日 13:23:29 [INFO] ✅ JavaScript によるクッキー設定成功
20XX年_XX月XX日 13:23:29 [INFO] 🟢 出力例
```

```
// MARK: - SwiftUI Wrapper (WebViewContainer)
struct WebViewContainer: View {
    @State private var proximityTimer: Timer? // タイマー管理用の State 変数

    var body: some View {
            VStack {
                Text("てきすと")
                    .font(.system(size: 30))
                    .padding()
            }
            .onAppear {
                DebugLogger.shared.log("🟢 WebViewContainer が表示されました", level: "INFO")
                usedata.locationManager.startUpdatingLocation()
                // 更新処理は isUpdateGps が true の場合にのみ実行
                if usedata.isUpdateGps {
                    usedata.getClosestLocation()
                }
            }

    }
```

# フルコード

#### WebView.swift
```
import SwiftUI
import WebKit
import MapKit
import CoreLocation

//MARK: - WebViewの定義
struct WebView: UIViewRepresentable {
    @EnvironmentObject var usedata: UserSettings
    static let sharedProcessPool = WKProcessPool()

    //MARK: - makeUIView
    func makeUIView(context: Context) -> WKWebView {
        DebugLogger.shared.log("📂 WebView を作成開始", level: "INFO")

        let webViewConfiguration = WKWebViewConfiguration()
        // JavaScript の有効化設定 (iOS 14以降対応)
        if #available(iOS 14.0, *) {
            let preferences = WKWebpagePreferences()
            preferences.allowsContentJavaScript = true
            webViewConfiguration.defaultWebpagePreferences = preferences
        } else {
            webViewConfiguration.preferences.javaScriptEnabled = true
        }
        
        webViewConfiguration.processPool = WKProcessPool()
        webViewConfiguration.websiteDataStore = WKWebsiteDataStore.default()

        let webView = WKWebView(frame: .zero, configuration: webViewConfiguration)
        webView.navigationDelegate = context.coordinator
        webView.configuration.userContentController.add(context.coordinator, name: "messageHandler")

        // ピンチイン・アウトを無効化
        webView.scrollView.pinchGestureRecognizer?.isEnabled = false
        if let gestures = webView.scrollView.gestureRecognizers {
            gestures.forEach { gesture in
                if gesture is UIPinchGestureRecognizer {
                    gesture.isEnabled = false
                }
            }
        }

        DebugLogger.shared.log("✅ WebView を作成しました", level: "INFO")

        DispatchQueue.main.async {
            self.setCookie(for: webView) {
                DebugLogger.shared.log("✅ クッキー設定完了", level: "INFO")
            }
        }

        DispatchQueue.main.async {
            self.loadWebView(webView)
        }

        return webView
    }

    //MARK: - キャッシュクリア
    func clearCache() {
        DebugLogger.shared.log("🗑 WebView のキャッシュ削除を開始", level: "INFO")

        let websiteDataTypes: Set<String> = [
            WKWebsiteDataTypeCookies,
            WKWebsiteDataTypeLocalStorage,
            WKWebsiteDataTypeSessionStorage,
            WKWebsiteDataTypeIndexedDBDatabases,
            WKWebsiteDataTypeWebSQLDatabases,
            WKWebsiteDataTypeFetchCache,
            WKWebsiteDataTypeDiskCache,
        ]

        let dateFrom = Date(timeIntervalSince1970: 0)
        WKWebsiteDataStore.default().removeData(ofTypes: websiteDataTypes, modifiedSince: dateFrom) {
            DebugLogger.shared.log("✅ WebView のキャッシュが削除されました", level: "INFO")
        }
    }

    //MARK: - setCookie

    private func setCookie(for webView: WKWebView, completion: @escaping () -> Void) {
        guard !usedata.isCookieSet else {
            DebugLogger.shared.log("⚠️ クッキーは既に設定済みのためスキップ", level: "INFO")
            completion()
            return
        }

        DispatchQueue.main.async {
            guard let url = URL(string: usedata.initialUrl) else {
                DebugLogger.shared.log("❌ クッキー設定失敗: 無効な URL", level: "ERROR")
                return
            }

            let domain = url.host ?? "carptaxi-miyajima.web.app"
            let cookieProperties: [HTTPCookiePropertyKey: Any] = [
                .domain: domain,
                .path: "/",
                .name: "idToken",
                .value: self.usedata.idToken,
                .secure: true,
                .expires: Date().addingTimeInterval(3600),
                .sameSitePolicy: "None"
            ]

            if let cookie = HTTPCookie(properties: cookieProperties) {
                let cookieStore = webView.configuration.websiteDataStore.httpCookieStore
                cookieStore.setCookie(cookie) {
                    DebugLogger.shared.log("✅ クッキー設定成功: \(cookie)", level: "INFO")
                    usedata.isCookieSet = true // クッキー設定済みフラグを true にする
                    completion()
                }
            } else {
                DebugLogger.shared.log("❌ クッキーの作成に失敗", level: "ERROR")
                completion()
            }
        }
    }


    //MARK: - ロード
    private func loadWebView(_ webView: WKWebView) {
        DispatchQueue.main.async {
            guard let url = URL(string: usedata.initialUrl) else {
                DebugLogger.shared.log("❌ WebView のロード失敗: 無効な URL", level: "ERROR")
                return
            }
            let request = URLRequest(url: url)
            webView.load(request)
            DebugLogger.shared.log("✅ WebView をロード: \(usedata.initialUrl)", level: "INFO")
        }
    }

    //MARK: - アップデート
    func updateUIView(_ uiView: WKWebView, context: Context) {
        let jsCommand = """
        document.cookie = 'idToken=\(usedata.idToken); path=/; Secure; SameSite=None';
        console.log('✅ idTokenクッキーがセットされました: ' + document.cookie);
        """
        
        uiView.evaluateJavaScript(jsCommand) { _, error in
            if let error = error {
                DebugLogger.shared.log("❌ JavaScript 実行失敗: \(error.localizedDescription)", level: "ERROR")
            } else {
                DebugLogger.shared.log("✅ JavaScript によるクッキー設定成功", level: "INFO")
            }
        }
    }


    //MARK: - Coordinator
    class Coordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler {
        func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
            if message.name == "messageHandler", let body = message.body as? String {
                DebugLogger.shared.log("📩 WebView からメッセージ受信: \(body)", level: "INFO")
            }
        }
    }

    func makeCoordinator() -> Coordinator {
        return Coordinator()
    }
}
```

#### WebViewContainer.swift
```
import SwiftUI
import WebKit
import MapKit
import CoreLocation

// MARK: - SwiftUI Wrapper (WebViewContainer)
struct WebViewContainer: View {
    @EnvironmentObject var usedata: UserSettings
    public var webView = WebView()
    @State private var proximityTimer: Timer? // タイマー管理用の State 変数

    var body: some View {
        self.webView
            .edgesIgnoringSafeArea(.all)
            .fullScreenCover(isPresented: $usedata.isVideoPlayerPresented) {
                if let videoURL = usedata.selectedVideoURL {
                    VideoPlayerView(videoURL: videoURL)
                } else {
                    Text("動画が見つかりませんでした")
                }
            }
            .onAppear {
                DebugLogger.shared.log("🟢 WebViewContainer が表示されました", level: "INFO")
                usedata.locationManager.startUpdatingLocation()
                // 更新処理は isUpdateGps が true の場合にのみ実行
                if usedata.isUpdateGps {
                    usedata.getClosestLocation()
                }
            }
            .onDisappear {
                stopProximityCheck() // 画面が閉じたらタイマーを停止
            }
            .onChange(of: usedata.isUpdateGps) { oldValue, newValue in
                if newValue {
                    DebugLogger.shared.log("🟢 位置情報の更新が開始されたため、10秒ごとの処理を開始", level: "INFO")
                    startProximityCheck()
                } else {
                    DebugLogger.shared.log("🛑 位置情報の更新が停止されたため、10秒ごとの処理を停止", level: "INFO")
                    stopProximityCheck()
                }
            }
    }

    /// 10秒ごとに処理を実行
    private func startProximityCheck() {
        stopProximityCheck() // 既存のタイマーがある場合は停止してから新規作成
        proximityTimer = Timer.scheduledTimer(withTimeInterval: 10.0, repeats: true) { _ in
            DebugLogger.shared.log("⏳ 10秒ごとの処理を実行", level: "INFO")
            usedata.getClosestLocation() // ここに実行したい処理を記述
        }
    }

    /// タイマーを停止
    private func stopProximityCheck() {
        proximityTimer?.invalidate()
        proximityTimer = nil
        DebugLogger.shared.log("🛑 タイマーが停止されました", level: "INFO")
    }
}
```