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

# 基本は二重起動できない

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

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

<img width="80" alt="calc.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/283293/e8dd47a6-8a61-c2fd-fa73-ba55e27e9859.png" />

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

（`macOS 14.6.1 / Xcode 15.4 / Swift 5.10`で確認済み）


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

1. コマンドで二重起動する
1. （プログラム）コードで二重起動する


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

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

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

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


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

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

```swift: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`を指定することで、新しいインスタンスで起動されます。

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


| 電卓アプリを3つ起動した例 |
|:-:|
| ![scr2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/283293/4ff4abc2-efb8-3c7e-50b5-d8610ee63496.png)  |

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

![multi1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/283293/7eb85b22-c096-be5f-76df-170b8956660d.png)

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

```swift: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
    }

}
```

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

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

:::



## SandBoxとアクセシビリティ

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

1．`アプリ名.entitlements`の`App SandBox`を`NO`に変更する

![sandbox.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/283293/49314806-8b34-923c-48be-99367bc4c56b.png)

<br>2．「アクセシビリティ アクセス」ダイアログ

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

<img width="300" alt="dlg1.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/283293/73c795a5-3f9e-df0e-478e-6807403f24d6.png" />

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

<img width="300" alt="dlg2.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/283293/cc1cc001-9b68-8649-79e8-5cf43d8b2411.png" />

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

---
---



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

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

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

https://www.abt.jp/kb/2013/06/macapp-prohibitation-dual-boot/

<details><summary><i>上記ブログ記事に掲載されている Objective-C コード</i></summary>

```objc: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!");
    }
}
```

</details>


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

```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アプリの二重起動を防止する方法については、次の記事が参考になります。

https://qiita.com/Subara3/items/50da6ee5885d576b429b

<br><br><br>**以上**