CahtGPTを使ってMacのネイティブアプリを作ってみよう〜その2〜

プログラミング

テスト的に定期的にマウスをクリックするプログラムが作れましたので、今回はメニューバーに表示したいと思います。

やりたいこと

画面の右上に常駐している状態で、有効/無効の状態が視覚的にわかりやすく、クリックするごとに有効/無効がトグルに動作するようなものにしたいと思います。大したものでないのであまりリッチにする必要もないですし、使いやすさ重視です。

クリック感覚などを可変にできるとかできると良いと思いますが、目的はシンクラの画面を定期的にクリックしてログオフさせないようにすることなので、一旦作ってみてから拡張は考えていきたいと思います。

いざ、ChatGPT

では、さっそくGPTに聞いてみましょう。「以下のコードにメニューバーにアプリが表示されるようにしてください。(改行して、前回のソースコードを貼り付け)」と送信したところ、以下のようなコードを作成してくれました。

import SwiftUI
import Cocoa

@main
struct MenuBarApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate

var body: some Scene {
Settings {
EmptyView()
}
}
}

class AppDelegate: NSObject, NSApplicationDelegate {
var statusItem: NSStatusItem?
var popover: NSPopover?

func applicationDidFinishLaunching(_ notification: Notification) {
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
if let button = statusItem?.button {
button.image = NSImage(systemSymbolName: “star.fill”, accessibilityDescription: nil) // アイコンを設定
button.action = #selector(togglePopover)
}

popover = NSPopover()
popover?.contentViewController = NSHostingController(rootView: ContentView())
}

@objc func togglePopover() {
if let button = statusItem?.button {
if popover?.isShown == true {
popover?.performClose(nil)
} else {
popover?.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
}
}
}
}

struct ContentView: View {
@State private var counter = 0
@State private var timer: Timer?

var body: some View {
VStack {
Text(“Counter: \(counter)”)
.font(.largeTitle)
.padding()

Button(action: {
self.buttonClicked()
}) {
Text(“Click Me”)
.font(.title)
.padding()
.background(Color.blue)
.foregroundColor(.white)
.cornerRadius(10)
}
}
.onAppear {
startTimer()
}
.onDisappear {
stopTimer()
}
}

func buttonClicked() {
counter += 1
simulateMouseClick()
}

func startTimer() {
timer = Timer.scheduledTimer(withTimeInterval: 60, repeats: true) { _ in
self.buttonClicked()
}
}

func stopTimer() {
timer?.invalidate()
timer = nil
}

func simulateMouseClick() {
let loc = NSEvent.mouseLocation
let clickEvent = CGEvent(mouseEventSource: nil, mouseType: .leftMouseDown, mouseCursorPosition: loc, mouseButton: .left)
clickEvent?.post(tap: .cghidEventTap)

let releaseEvent = CGEvent(mouseEventSource: nil, mouseType: .leftMouseUp, mouseCursorPosition: loc, mouseButton: .left)
releaseEvent?.post(tap: .cghidEventTap)
}
}

struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}

とりあえず、貼り付けて動作確認してみましたが、エラーとなったので、そのエラー内容を解消してもらいます。「’main’ attribute can only apply to one type in a moduleを解消してください」と送ってみます。すると以下のようなコードを提案してくれました。

import SwiftUI
import Cocoa

@main
struct MenuBarApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate

var body: some Scene {
Settings {
EmptyView() // メインのビューは使用しない
}
}
}

class AppDelegate: NSObject, NSApplicationDelegate {
var statusItem: NSStatusItem?
var popover: NSPopover?

func applicationDidFinishLaunching(_ notification: Notification) {
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
if let button = statusItem?.button {
button.image = NSImage(systemSymbolName: “star.fill”, accessibilityDescription: nil) // アイコンを設定
button.action = #selector(togglePopover)
}

popover = NSPopover()
popover?.contentViewController = NSHostingController(rootView: ContentView())
}

@objc func togglePopover() {
if let button = statusItem?.button {
if popover?.isShown == true {
popover?.performClose(nil)
} else {
popover?.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
}
}
}
}

struct ContentView: View {
@State private var counter = 0
@State private var timer: Timer?

var body: some View {
VStack {
Text(“Counter: \(counter)”)
.font(.largeTitle)
.padding()

Button(action: {
self.buttonClicked()
}) {
Text(“Click Me”)
.font(.title)
.padding()
.background(Color.blue)
.foregroundColor(.white)
.cornerRadius(10)
}
}
.onAppear {
startTimer()
}
.onDisappear {
stopTimer()
}
}

func buttonClicked() {
counter += 1
simulateMouseClick()
}

func startTimer() {
timer = Timer.scheduledTimer(withTimeInterval: 60, repeats: true) { _ in
self.buttonClicked()
}
}

func stopTimer() {
timer?.invalidate()
timer = nil
}

func simulateMouseClick() {
let loc = NSEvent.mouseLocation
let clickEvent = CGEvent(mouseEventSource: nil, mouseType: .leftMouseDown, mouseCursorPosition: loc, mouseButton: .left)
clickEvent?.post(tap: .cghidEventTap)

let releaseEvent = CGEvent(mouseEventSource: nil, mouseType: .leftMouseUp, mouseCursorPosition: loc, mouseButton: .left)
releaseEvent?.post(tap: .cghidEventTap)
}
}

struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}

まだ同じエラーが出るので、GPTを使わずに目検で確認してみます。すると、App側とContentView側のどちらにもmainがあるのが原因とわかりましたので、ContentView側を全削除し、App側に上記のコードを貼り付けることにしました。

エラーは解決し、メニューバーにも表示され、カウンターは動作しているものの、ウィンドウがアクティブにならない現象が再現されてしまいました。システム設定を確認しても、アクセシビリティはちゃんとONになっていました。

とりあえず、メニューバーから開始と終了できるようにしたいので、これをGPTにお願いしてみます。「以下のプログラムのメニューバーに開始と終了のボタンを付けて」すると以下のようなコードを生成してくれました。(タイマーはテスト用に3秒にしていたので、3秒周期になってます。)

import SwiftUI
import Cocoa

@main
struct MenuBarApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate

var body: some Scene {
Settings {
EmptyView()
}
}
}

class AppDelegate: NSObject, NSApplicationDelegate {
var statusItem: NSStatusItem?
var popover: NSPopover?
var timer: Timer?

func applicationDidFinishLaunching(_ notification: Notification) {
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)

if let button = statusItem?.button {
button.image = NSImage(systemSymbolName: “star.fill”, accessibilityDescription: nil) // アイコンを設定
}

let menu = NSMenu()

let startItem = NSMenuItem(title: “開始”, action: #selector(startTimer), keyEquivalent: “S”)
menu.addItem(startItem)

let stopItem = NSMenuItem(title: “終了”, action: #selector(stopTimer), keyEquivalent: “E”)
menu.addItem(stopItem)

statusItem?.menu = menu
}

@objc func startTimer() {
stopTimer() // 既存のタイマーがある場合は停止
timer = Timer.scheduledTimer(withTimeInterval: 3, repeats: true) { _ in
self.simulateMouseClick()
}
print(“Timer started”)
}

@objc func stopTimer() {
timer?.invalidate()
timer = nil
print(“Timer stopped”)
}

func simulateMouseClick() {
let loc = NSEvent.mouseLocation
print(“Simulating mouse click at \(loc)”) // クリック位置をログに記録
let clickEvent = CGEvent(mouseEventSource: nil, mouseType: .leftMouseDown, mouseCursorPosition: loc, mouseButton: .left)
clickEvent?.post(tap: .cghidEventTap)

let releaseEvent = CGEvent(mouseEventSource: nil, mouseType: .leftMouseUp, mouseCursorPosition: loc, mouseButton: .left)
releaseEvent?.post(tap: .cghidEventTap)
}
}

struct ContentView: View {
@State private var counter = 0

var body: some View {
VStack {
Text(“Counter: \(counter)”)
.font(.largeTitle)
.padding()

Button(action: {
self.counter += 1
self.simulateMouseClick()
}) {
Text(“Click Me”)
.font(.title)
.padding()
.background(Color.blue)
.foregroundColor(.white)
.cornerRadius(10)
}
}
}

func simulateMouseClick() {
let loc = NSEvent.mouseLocation
print(“Simulating mouse click at \(loc)”) // クリック位置をログに記録
let clickEvent = CGEvent(mouseEventSource: nil, mouseType: .leftMouseDown, mouseCursorPosition: loc, mouseButton: .left)
clickEvent?.post(tap: .cghidEventTap)

let releaseEvent = CGEvent(mouseEventSource: nil, mouseType: .leftMouseUp, mouseCursorPosition: loc, mouseButton: .left)
releaseEvent?.post(tap: .cghidEventTap)
}
}

struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}

理想にはまだ届かないものの、イメージしていたものが出来つつあります。今回はここまでにして、なぜカーソル位置にあるものがアクティブ化されないのか問題は引き続き調査していこうと思います。