From 88297cbc9106a24bf7cceb5398d70ff10a68cfca Mon Sep 17 00:00:00 2001 From: Ultradesu Date: Tue, 9 Jun 2026 10:52:49 +0100 Subject: [PATCH] feat: real-time logs window + v1.3 Add AppLogger singleton (ring buffer, 500 lines) and LogsView with auto-scroll and color-coded output. Button in Settings opens a resizable logs window. Log track start, device active changes, and network errors. Co-Authored-By: Claude Sonnet 4.6 --- furumi_macos.xcodeproj/project.pbxproj | 4 +- furumi_macos/AppLogger.swift | 36 ++++++++++++++ furumi_macos/ContentView.swift | 6 ++- furumi_macos/DeviceManager.swift | 7 ++- furumi_macos/LogsView.swift | 66 ++++++++++++++++++++++++++ furumi_macos/PlayerManager.swift | 1 + furumi_macos/SettingsView.swift | 11 +++++ furumi_macos/furumi_macosApp.swift | 24 ++++++++++ 8 files changed, 149 insertions(+), 6 deletions(-) create mode 100644 furumi_macos/AppLogger.swift create mode 100644 furumi_macos/LogsView.swift diff --git a/furumi_macos.xcodeproj/project.pbxproj b/furumi_macos.xcodeproj/project.pbxproj index cfc8e2f..271d82c 100644 --- a/furumi_macos.xcodeproj/project.pbxproj +++ b/furumi_macos.xcodeproj/project.pbxproj @@ -409,7 +409,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 1.2; + MARKETING_VERSION = 1.3; PRODUCT_BUNDLE_IDENTIFIER = "hexor.furumi-macos"; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; @@ -442,7 +442,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 1.2; + MARKETING_VERSION = 1.3; PRODUCT_BUNDLE_IDENTIFIER = "hexor.furumi-macos"; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; diff --git a/furumi_macos/AppLogger.swift b/furumi_macos/AppLogger.swift new file mode 100644 index 0000000..bb7aaaf --- /dev/null +++ b/furumi_macos/AppLogger.swift @@ -0,0 +1,36 @@ +// +// AppLogger.swift +// furumi_macos +// + +import Foundation +import Observation + +@MainActor +@Observable +final class AppLogger { + static let shared = AppLogger() + + private(set) var lines: [String] = [] + private let maxLines = 500 + + private init() {} + + func log(_ message: String) { + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm:ss.SSS" + let timestamp = formatter.string(from: Date()) + lines.append("[\(timestamp)] \(message)") + if lines.count > maxLines { + lines.removeFirst(lines.count - maxLines) + } + } + + func clear() { + lines.removeAll() + } +} + +func appLog(_ message: String) { + Task { @MainActor in AppLogger.shared.log(message) } +} diff --git a/furumi_macos/ContentView.swift b/furumi_macos/ContentView.swift index 22fbc76..fd51033 100644 --- a/furumi_macos/ContentView.swift +++ b/furumi_macos/ContentView.swift @@ -107,11 +107,13 @@ struct ContentView: View { deviceManager.start(service: service, player: player) let am = authManager player.onTrackStarted = { trackId in - try? await am.catalogService?.lastfmNowPlaying(trackId: trackId) + do { try await am.catalogService?.lastfmNowPlaying(trackId: trackId) } + catch { appLog("❌ lastfm now-playing error: \(error)") } } player.onTrackFinished = { trackId, startedAt, durationListened, completed in guard let svc = am.catalogService else { return } - try? await svc.recordHistory(trackId: trackId, startedAt: startedAt, durationListened: durationListened, completed: completed) + do { try await svc.recordHistory(trackId: trackId, startedAt: startedAt, durationListened: durationListened, completed: completed) } + catch { appLog("❌ history record error: \(error)") } } let gvm = globalVM Task { await gvm?.loadFirstPage() } diff --git a/furumi_macos/DeviceManager.swift b/furumi_macos/DeviceManager.swift index b359c82..08ada71 100644 --- a/furumi_macos/DeviceManager.swift +++ b/furumi_macos/DeviceManager.swift @@ -94,7 +94,9 @@ final class DeviceManager { ) let response = try decoder.decode(DevicePollResponseDTO.self, from: data) applyPollResponse(response, player: player) - } catch { } + } catch { + appLog("❌ Device poll error: \(error)") + } } private func applyPollResponse(_ response: DevicePollResponseDTO, player: PlayerManager) { @@ -104,10 +106,11 @@ final class DeviceManager { activeDeviceId = response.activeDeviceId if isThisDeviceActive { + if !wasActive { appLog("📱 This device became active") } remoteState = nil } else { + if wasActive { appLog("📱 This device became inactive, active: \(response.activeDeviceId ?? "none")") } remoteState = response.playbackState - // Pause local playback when this device is no longer active if wasActive { player.pauseIfNeeded() } diff --git a/furumi_macos/LogsView.swift b/furumi_macos/LogsView.swift new file mode 100644 index 0000000..510a182 --- /dev/null +++ b/furumi_macos/LogsView.swift @@ -0,0 +1,66 @@ +// +// LogsView.swift +// furumi_macos +// + +import SwiftUI + +struct LogsView: View { + @State private var logger = AppLogger.shared + + var body: some View { + VStack(spacing: 0) { + HStack { + Text("Logs") + .font(.headline) + Spacer() + Text("\(logger.lines.count) lines") + .font(.caption) + .foregroundColor(.secondary) + Button("Clear") { logger.clear() } + .buttonStyle(.bordered) + .controlSize(.small) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + + Divider() + + ScrollViewReader { proxy in + ScrollView { + LazyVStack(alignment: .leading, spacing: 0) { + ForEach(Array(logger.lines.enumerated()), id: \.offset) { idx, line in + Text(line) + .font(.system(size: 11, design: .monospaced)) + .foregroundColor(color(for: line)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 10) + .padding(.vertical, 1) + .id(idx) + } + } + .padding(.vertical, 4) + } + .onChange(of: logger.lines.count) { _, _ in + if let last = logger.lines.indices.last { + proxy.scrollTo(last, anchor: .bottom) + } + } + .onAppear { + if let last = logger.lines.indices.last { + proxy.scrollTo(last, anchor: .bottom) + } + } + } + } + .frame(width: 680, height: 420) + } + + private func color(for line: String) -> Color { + if line.contains("error") || line.contains("Error") || line.contains("❌") { return .red } + if line.contains("warn") || line.contains("Warn") || line.contains("⚠️") { return .orange } + if line.contains("▶") { return .accentColor } + return .primary + } +} diff --git a/furumi_macos/PlayerManager.swift b/furumi_macos/PlayerManager.swift index cd7bafc..3f9ee47 100644 --- a/furumi_macos/PlayerManager.swift +++ b/furumi_macos/PlayerManager.swift @@ -97,6 +97,7 @@ final class PlayerManager { player?.play() isPlaying = true currentTrack = track + appLog("▶ \(track.title) — \(track.artistNames)") currentTime = 0 duration = Double(track.durationSeconds) trackStartedAt = Date() diff --git a/furumi_macos/SettingsView.swift b/furumi_macos/SettingsView.swift index c966ebb..0281af0 100644 --- a/furumi_macos/SettingsView.swift +++ b/furumi_macos/SettingsView.swift @@ -18,6 +18,17 @@ struct SettingsView: View { Divider() authSection Spacer() + Divider() + HStack { + Spacer() + Button { + NSApp.sendAction(#selector(AppDelegate.openLogs), to: nil, from: nil) + } label: { + Label("View Logs", systemImage: "doc.text.magnifyingglass") + } + .buttonStyle(.bordered) + .controlSize(.small) + } } .padding(24) .frame(width: 360) diff --git a/furumi_macos/furumi_macosApp.swift b/furumi_macos/furumi_macosApp.swift index cb1284d..f595b05 100644 --- a/furumi_macos/furumi_macosApp.swift +++ b/furumi_macos/furumi_macosApp.swift @@ -19,6 +19,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { var statusItem: NSStatusItem! var popover: NSPopover! var settingsWindow: NSWindow? + var logsWindow: NSWindow? let authManager = AuthManager() func applicationDidFinishLaunching(_ notification: Notification) { @@ -108,6 +109,29 @@ class AppDelegate: NSObject, NSApplicationDelegate { NSApp.activate(ignoringOtherApps: true) } + @objc func openLogs() { + if let window = logsWindow { + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + return + } + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 680, height: 420), + styleMask: [.titled, .closable, .resizable], + backing: .buffered, + defer: false + ) + window.title = "Logs" + window.contentViewController = NSHostingController(rootView: LogsView()) + window.center() + window.isReleasedWhenClosed = false + logsWindow = window + + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + } + @objc func quit() { NSApplication.shared.terminate(nil) }