1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

[Mac] アプリケーションを二重起動する方法、二重起動をチェックする方法

Posted at

基本は二重起動できない

すでに起動しているアプリを、LaunchPad や Finder から、再び クリック / ダブルクリックしても、二重起動しません。これは、macOS(の標準ランチャー)が二重起動を制御しているためです。よって、アプリ側でわざわざ二重起動に対する処理を持つ必要は「原則」ありません。

しかし、例えば、電卓アプリなどを複数起動したいと思っても、それができません。二重起動できないことが、返って不便なこともあります。

calc.png

今回は、macOS標準の電卓アプリ(計算機.app)を例に、アプリを二重起動(複数起動)する 2つの方法を紹介します。

macOS 14.6.1 / Xcode 15.4 / Swift 5.10で確認済み)

アプリケーションを二重起動する 2つの方法

  1. コマンドで二重起動する
  2. (プログラム)コードで二重起動する

1. コマンドで二重起動する

次のコマンドを実行すると、実行した数だけ電卓アプリを起動することができます。

ターミナル
open -n /System/Applications/Calculator.app

-nオプション:新しいインスタンスで起動する

2. コードで二重起動する

次のコードを実行すると、実行した数だけ電卓アプリを起動することができます。

Swift
import AppKit
let path = "/System/Applications/Calculator.app"
let appURL = URL(fileURLWithPath: path)
let conf = NSWorkspace.OpenConfiguration()
conf.createsNewApplicationInstance = true
conf.addsToRecentItems = false
let newApp = NSWorkspace.shared.open(appURL, configuration: conf, completionHandler: { launchedApp, error in
   if let error {
      print("Could not launch new app: \(error.localizedDescription)")
   } else if let launchedApp {
      print("Launched app process id: \(launchedApp.processIdentifier)")
   }
})
//Thread.sleep(forTimeInterval: 1) //required for Swift REPL 

conf.createsNewApplicationInstance = trueを指定することで、新しいインスタンスで起動されます。

コマンド・コード共に、起動したアプリのウィンドウ位置が どれも同じ(ウィンドウが重なって起動される)ため、移動する必要があります。

電卓アプリを3つ起動した例
scr2.png

アプリ多重起動 & ウィンドウ位置調整

multi1.png

上記スクショのSwiftUIアプリは、指定したアプリを多重起動すると共に、自動的にウィンドウの位置を調整します(上記スクショのように、ウィンドウを少しずつずらす)。
All Closeボタンで、起動したアプリを一斉に終了します。
コードは以下のとおり。

SwiftUI
import SwiftUI
import AppKit

var apps = [NSRunningApplication]()

struct ContentView: View {
    @State var appPath = "/System/Applications/Calculator.app"
    var body: some View {
        VStack {
            HStack(alignment: .top) {
                Text("App Path:")
                TextEditor(text: $appPath)
                    .lineLimit(1)
                    .font(.system(size: 12))
                    .frame(height: 21)
                Image(systemName: "x.circle")
                    .onTapGesture {
                        appPath = ""
                    }
                Button("Lauch", action: {
                    if FileManager.default.fileExists(atPath: appPath.trimmingCharacters(in: .whitespacesAndNewlines), isDirectory: nil) {
                        launchApp(appPath)
                    } else {
                        print("file not found: \(appPath)")
                    }
                })
            }
            HStack {
                Spacer()
                Button("All Close", action: {
                    if apps.isEmpty { return }
                    for app in apps.reversed() {
                        app.terminate()
                    }
                    apps.removeAll()
                })
            }
        }
        .padding()
    }
    
    func launchApp(_ path: String) {
        let appURL = URL(fileURLWithPath: path)
        let conf = NSWorkspace.OpenConfiguration()
        conf.createsNewApplicationInstance = true
        conf.addsToRecentItems = false
        NSWorkspace.shared.open(appURL, configuration: conf, completionHandler: { runningApp, error in
            if let error {
                print("Cannot start new app: \(error.localizedDescription)")
            } else if let runningApp {
                var point: CGPoint? = nil
                if !apps.isEmpty {
                    let lastApp = apps.last!
                    let lastAppPID = lastApp.processIdentifier
                    lastApp.activate()
                    Thread.sleep(forTimeInterval: 0.1) //wait for activate
                    if let window = getFocusedWindow(pid: lastAppPID) {
                        if let origin = getOrigin(element: window) {
                            point = origin
                        }
                    }
                }
                if var point {
                    point = CGPoint(x: point.x + 20, y: point.y + 20)
                    let appPID = runningApp.processIdentifier
                    runningApp.activate()
                    Thread.sleep(forTimeInterval: 0.1) //wait for activate
                    if let window = getFocusedWindow(pid: appPID) {
                        setOrigin(element: window, origin: point)
                    }
                }
                apps.append(runningApp)
                print("Launch app: \(runningApp.processIdentifier)")
            }
        })
    }
    func copyAttributeValue(_ element: AXUIElement, attribute: String) -> CFTypeRef? {
        var ref: CFTypeRef? = nil
        let rtn = AXUIElementCopyAttributeValue(element, attribute as CFString, &ref)
        return rtn == .success ? ref : nil
    }
    func setAttributeValue(_ element: AXUIElement, attribute: String, value: CFTypeRef) -> Bool {
        AXUIElementSetAttributeValue(element, attribute as CFString, value) == .success
    }
    func getFocusedWindow(pid: pid_t) -> AXUIElement? {
        let element = AXUIElementCreateApplication(pid)
        if let window = copyAttributeValue(element, attribute: kAXFocusedWindowAttribute) {
            return (window as! AXUIElement)
        }
        return nil
    }
    func getOrigin(element: AXUIElement) -> NSPoint? {
        var origin: NSPoint = .zero
        if let originRef = copyAttributeValue(element, attribute: kAXPositionAttribute) {
            if AXValueGetType(originRef as! AXValue).rawValue == kAXValueCGPointType && AXValueGetValue(originRef as! AXValue, AXValueType(rawValue: kAXValueCGPointType)!, &origin) {
                return origin
            }
        }
        return nil
    }
    @discardableResult
    func setOrigin(element: AXUIElement, origin: NSPoint) -> Bool {
        var origin = origin
        if let value = AXValueCreate(AXValueType.cgPoint, &origin) {
            return setAttributeValue(element, attribute: kAXPositionAttribute, value: value)
        }
        return false
    }

}

「App Path」フィールドをクリア後、アプリアイコンをドラッグ&ドロップすると、そのアプリのフルパスが設定されます。

https://qiita.com/nak435/items/2f21cd8c10c04a6fe4ec

SandBoxとアクセシビリティ

上のアプリを動作させるためには、SandBox環境のオフと、アクセシビリティ権限を与える必要があります。

1.アプリ名.entitlementsApp SandBoxNOに変更する

sandbox.png


2.「アクセシビリティ アクセス」ダイアログ

アプリを起動すると「アクセシビリティ アクセス」ダイアログが表示されるので、「システム設定を開く」をクリックする。

dlg1.png

「システム設定」→「プライバシーとセキュリティ」→「アクセシビリティ」が開くので、当アプリを「ON」にする。

dlg2.png


以上の設定により、以降のウィンドウのずらしが機能するはずです。



アプリの二重起動をチェックする方法

一方、アプリによっては、複数のインスタンスが起動されては困る(1つのインスタンスで処理する必要があるアプリの)場合は、アプリ自身で二重起動をチェックする必要があります。

アプリの二重起動をチェックする方法として、次のブログ記事がヒットします。

上記ブログ記事に掲載されている Objective-C コード
Objective-C
//自分のプロセスID
pid_t myPID = [[NSProcessInfo processInfo] processIdentifier];
//自分のBundleID
NSString* myBundleID = [[NSBundle mainBundle] bundleIdentifier];
//自分の起動パス
NSURL* myURL = [[NSBundle mainBundle] executableURL];

//現在起動中のアプリケーション群を取得    
NSArray* arr = [[NSWorkspace sharedWorkspace] runningApplications];
for (NSRunningApplication* app in arr) {
    //自分自身。これは無視した方が良い
    if (myPID == app.processIdentifier) {
        NSLog(@"It's me!");
    }

    //同じパスから起動したプロセス。ここで一致するようなら自分自身を終了する
    if ([myURL app.executableURL]) {
        NSLog(@"It's same File!");
    }

    //起動パスは違うが同一アプリケーション。必要によってはこのケースでも終了する
    if ([app.bundleIdentifier isEqualToString:myBundleID]) {
        NSLog(@"It's same App!");
    }
}

Swift が登場する前の 2013年の発信ですから、Objective-C で書かれています。これを Swift に書き直したのが次のコードです。

Swift
//自分のプロセスID
let myPID = ProcessInfo.processInfo.processIdentifier
//自分のBundleID
let myBundleID = Bundle.main.bundleIdentifier
//自分の起動パス
let myURL = Bundle.main.executableURL

//現在起動中のアプリケーション群を取得
let arr = NSWorkspace.shared.runningApplications
for app in arr {
    //自分自身。これは無視した方が良い
    if myPID == app.processIdentifier {
        NSLog("It's me!")
    }
    
    //同じパスから起動したプロセス。ここで一致するようなら自分自身を終了する
    if myURL == app.executableURL {
        NSLog("It's same File!")
    }
    
    //起動パスは違うが同一アプリケーション。必要によってはこのケースでも終了する
    if myBundleID == app.bundleIdentifier {
        NSLog("It's same App!")
    }
}        

海外を含むブログやQ&Aのサイトをいろいろ探しましたが、どれも基本は、NSWorkspace.runningApplicationsを使用したものでした。

これの使い方については、別な記事で紹介したいと思います。

参考

Windowsアプリの二重起動を防止する方法については、次の記事が参考になります。




以上

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?