[Delphi] macOS で Browser を開く時 POSIX の機能を使うと言ったな。あれは嘘だ。

# macOS でブラウザを開く

macOS でブラウザを開きたい！と思って[検索すると下のようなコードが引っかかる](https://blogs.embarcadero.com/opening-a-file-with-the-default-program-on-windows-and-mac/)と思います。

```pascal
_system(PAnsiChar('open ' + AnsiString(URL)));
```

macOS は POSIX 準拠 OS なので、POSIX の [system 関数](https://linuxjm.osdn.jp/html/LDP_man-pages/man3/system.3.html)経由で ```open``` コマンドが使えます。
つまり、macOS では POSIX API を使えば簡単にブラウザが開けるのでこれでええじゃろ、という事です。

でも！本当に大丈夫なんでしょうか！

# 実験

さて、それでは Windows と macOS で下記の URL にアクセスしてみます。

```text:URL
https://google.co.jp/$1234
```

これは存在しないパスです。
Google は存在しないパスにアクセスするとエラーページでこんなパスないよ～って表示してくれるので、今回はその機能を利用させて貰います。
（この機能を利用するだけで、存在しないパスだけの問題ではないです）

上記の URL にアクセスするコードは以下です。

```pascal:ブラウザを開く
procedure TForm1.Button1Click(Sender: TObject);
begin
  const URL = 'https://google.co.jp/$1234'; // URL

  // Windows 版
  {$IFDEF MSWINDOWS}
  ShellExecute(0, 'open', PChar(URL), nil, nil, SW_SHOW);
  {$ENDIF}

  // macOS 版
  {$IFDEF OSX}
  _system(PAnsiChar(AnsiString('open ' + iURL.QuotedString('"'))))
  {$ENDIF}
end;
```

Windows 版で使っている [ShellExecute](https://docs.microsoft.com/en-us/windows/win32/api/shellapi/nf-shellapi-shellexecutew) も system 関数と大体同じ機能を持つ API ですね。

## 実行結果
### Windows での結果
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/12977/f1000bbd-09da-f1e9-324d-138f5e7d7b89.png)

```/$1234``` なんていうパスないよ～って言ってますね。
期待通りです。

### macOS での結果
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/12977/06ceedb8-62bc-732f-d04d-7e6ed1631531.png)

**！？**
```/234``` なんてパスないよ～って言っています！
期待値と違いますね！？

# macOS の system 関数にはバグがある

上の結果の通り macOS の system 関数にはバグがあります。
確認した限り

* /$
* /%

というパスの時に```/```の後ろの文字が２つ消えるという物です。

```text:元
https://google.co.jp/$1234
```

↓ ```/```の後ろの文字が２つ消える ($ と後ろの文字が消える)

```text:macOSのsystem関数を通した結果
https://google.co.jp/234
```

/$ はまだしも URLEncode した URL では、/% なんて容易にありうるパスです。
これは致命的です。

この動作は macOS の system 関数が愚直に shell を呼び出しているせいで $ などの特殊文字が効いてしまっているからかもしれません。
試しに Terminal で ```open "https://google.com/$1234"``` とすると別の結果[^1]が得られるため、system 関数に問題がありそうです。

# macOS での正しいブラウザの開き方

ずばり↓こうです。

```pascal
// uses には Macapi.AppKit, Macapi.Helpers を追加する
var Workspace := TNSWorkspace.Wrap(TNSWorkspace.OCClass.sharedWorkspace);
Workspace.openURL(StrToNSUrl(iURL));
```
[NSWorksupace](https://developer.apple.com/documentation/appkit/nsworkspace?language=objc) の [openURL メソッド](https://developer.apple.com/documentation/appkit/nsworkspace/1533463-openurl)を使います。
たったこれだけ。
POSIX に負けず劣らず簡単ですね。

# まとめ

郷に入っては郷に従え、macOS ネイティブの API がある場合はそれを使った方が良さそうです。

# おまけ
Windows, macOS, Android, iOS, Linux 全てに対応したコードです。

* Windows は ShellExecute を使い、失敗した場合は WinExec で explorer を呼び出します。
* macOS は前述の通り、NSWorkspace.openURL を使っています。
* Android は暗黙的 Intent を使っています。
* iOS は [UIApplication.openURL](https://developer.apple.com/documentation/uikit/uiapplication/1622961-openurl?language=objc) が deprecated になっているため、自前で [openURL:options:completionHandler:](https://developer.apple.com/documentation/uikit/uiapplication/1648685-openurl) を定義して使っています（定義さえしてしまえば OS の API を自由に使えるのも Delphi の良いところですね）
* Linux は xdg-utils の xdg-open を使っています。

```pascal
(*
 * Browser Utils
 *
 * PLATFORMS
 *   Windows / macOS / iOS / Android / Linux (needs xdg-utils)
 *
 * USAGE
 *   OpenBrowser with URL
 *     TBrowserUtils.Open('URL');
 *
 * LICENSE
 *   Copyright (c) 2015, 2021 HOSOKAWA Jun
 *   Released under the MIT license
 *   http://opensource.org/licenses/mit-license.php
 *
 * HISTORY
 *   2015/11/27 Ver 1.0.0
 *   2021/01/20 Ver 1.0.1  macOS: _system open -> NSWorkspace.openURL
 *   2021/01/30 Ver 1.0.2  iOS: openURL -> openURL:options:completionHandler:
 *   2021/01/31 Ver 1.1.0  Global procedure -> record
 *
 * Programmed by HOSOKAWA Jun (twitter: @pik)
 *)

unit PK.Utils.Browser;

interface

type
  TBrowserUtils = record
  public
    class procedure Open(const iURL: String); static;
  end;

implementation

uses
  System.SysUtils

  {$IFDEF MSWINDOWS}
    , Winapi.Windows
    , Winapi.ShellAPI
  {$ENDIF}

  {$IFDEF OSX}
    , Macapi.AppKit
    , Macapi.Helpers
  {$ENDIF}

  {$IFDEF ANDROID}
    , Androidapi.JNI.Net
    , Androidapi.JNI.App
    , Androidapi.JNI.GraphicsContentViewText
    , Androidapi.Helpers
    , FMX.Helpers.Android
  {$ENDIF}

  {$IFDEF IOS}
    , iOSapi.Foundation
    , iOSapi.UIKit
    , Macapi.Helpers
    , Macapi.ObjectiveC
    , FMX.Helpers.IOS
  {$ENDIF}

  {$IFDEF LINUX}
    , Posix.StdLib
  {$ENDIF}
  ;

{$IFDEF IOS}
type
  // openURL は iOS 10.0 で deprecated になり
  // openURL:options:completionHandler: に変更しなくてはならなくなった
  // だが 2021/2 現在の Delphi 10.4.1 の UIApplication では
  // openURL:options:completionHandler: は定義されていないため自分で定義する
  UIApplicationEx = interface(UIApplication)
    [MethodName('openURL:options:completionHandler:')]
    function OpenURLoptionsCompletionHandler(
      url: NSURL;
      options: Pointer;
      completionHandler: Pointer): Boolean; cdecl;
  end;
  TUIApplicationEx =
    class(TOCGenericImport<UIApplicationClass, UIApplicationEx>)
    end;
{$ENDIF}

class procedure TBrowserUtils.Open(const iURL: String);
begin
  {$IFDEF MSWINDOWS}
    if (ShellExecute(0, 'open', PChar(iURL), nil, nil, SW_SHOW) < 32) then
      WinExec(PAnsiChar(AnsiString('explorer ' + iURL)), SW_SHOW)
  {$ENDIF}

  {$IFDEF OSX}
    var Workspace := TNSWorkspace.Wrap(TNSWorkspace.OCClass.sharedWorkspace);
    Workspace.openURL(StrToNSUrl(iURL));
  {$ENDIF}

  {$IFDEF ANDROID}
    var Intent :=
      TJIntent.JavaClass.init(
        TJIntent.JavaClass.ACTION_VIEW,
        StrToJURI(iURL));

    {$IF RTLVersion < 30}
      SharedActivity.startActivity(Intent);
    {$ELSE}
      TAndroidHelper.Activity.startActivity(Intent);
    {$ENDIF}
  {$ENDIF}

  {$IFDEF IOS}
    var App := TUIApplicationEx.Wrap(TUIApplication.OCClass.sharedApplication);
    var URI := TNSURL.Wrap(TNSURL.OCClass.URLWithString(StrToNSStr(iURL)));

    if TOSVersion.Check(10, 0) then
    begin
      var Dic := TNSDictionary.OCClass.dictionary;
      App.OpenURLoptionsCompletionHandler(URI, Dic, nil);
    end
    else
      App.OpenURL(URI);
  {$ENDIF}

  {$IFDEF LINUX}
    _system(PAnsiChar('xdg-open '+ AnsiString(iURL.QuotedString('"'))));
  {$ENDIF}
end;

end.
```

[^1]: 404 Error になるもののトップページが表示される