feed

2024年02月18日, 編集履歴

macOS App Sandbox下におけるSecurity-scoped Bookmarkについて

App Sandbox環境下のmacOSアプリにおいて、ローカルファイルへのアクセスには制限が課せられている。

コード内で適当に生成したURLではローカルファイルへアクセスできない。ファイルへのアクセス権を得るには、

等の限られた方法を取る必要がある。

ただしNSOpenPanel等で取得したURLは、その起動中はURL先のファイルにアクセス可能だが、そのURLを保存しておいたとしても、次の起動時にはアクセスできなくなってしまう。URLへのアクセスを永続化するには、Security-scopedなURL Bookmarkを作らなければならない。

URLからSecurity-scoped Bookmarkの生成

URLbookmarkData(options:includingResourceValuesForKeys:relativeTo:)関数を使ってSecurity-scoped Bookmark(Data型)を作成する。options引数にはwithSecurityScope を指定。URLへのアクセスが読み込みのみで良い場合は、securityScopeAllowOnlyReadAccess も併用する。

let bookmarkData = try? url.bookmarkData(options: [.withSecurityScope, .securityScopeAllowOnlyReadAccess], includingResourceValuesForKeys: nil, relativeTo: nil)

Security-scoped BookmarkからURLを生成し、アクセスする

URLinit(resolvingBookmarkData:options:relativeTo:bookmarkDataIsStale:)関数を使うことで、Security-scoped BookmarkなDataからURLを生成できる。resolvingBookmarkData引数にはSecurity-scoped BookmarkなDataを、options引数にはwithSecurityScopeを指定する。

生成されたURLに実際にアクセスするには、アクセス前にstartAccessingSecurityScopedResource()を実行し、アクセス後にはstopAccessingSecurityScopedResource()を実行する。

var isStale = false
let urlData = // 前項の手法で保存したURLのData
if let url = try? URL(resolvingBookmarkData: urlData, options: .withSecurityScope, relativeTo: nil, bookmarkDataIsStale: &isStale) {
  if url.startAccessingSecurityScopedResource() {

    // URL先への読み書き処理

    url.stopAccessingSecurityScopedResource()
  }
}

URLinit(resolvingBookmarkData:options:relativeTo:bookmarkDataIsStale:)実行後にisStaletrueになっている場合、Bookmark Data作成以降にURL先ファイルの名前が変更されたり、場所が変更されたことを意味する。その場合でも返却されたURLは変更後のものになっており、アクセスは可能だが、新しくSecurity-scoped Bookmarkを生成し直す必要がある。

Security-scoped Bookmarkを再生成するときも、URL先へのアクセスに相当するので、startAccessingSecurityScopedResource()stopAccessingSecurityScopedResource()が必要になる。

2024年02月17日, 編集履歴

SwiftUI Tableの使い方

以下のような構造体の配列をSwiftUIのTableビューに表示する場合を考える。

struct Bookmark {
  var title: String
  var url: URL
}

単純に表形式で表示する

データを単純にTableに表示したいだけの場合、Tableinit<Data>(Data, columns: () -> Columns)と、TableColumninit(Text, content: (RowValue) -> Content)あたりを使う。このとき、表示するデータ構造はIdentifiableプロトコルに適合させる必要があるので、プロパティにidを追加した。

import SwiftUI

struct Bookmark: Identifiable {

  var id = UUID()
  var title: String
  var url: URL

}

struct ContentView: View {

  @State var sampleData: [Bookmark] = [
    Bookmark(title: "Genji App", url: URL(string: "https://genjiapp.com/")!),
    Bookmark(title: "Apple", url: URL(string: "https://www.apple.com/")!),
    Bookmark(title: "Google", url: URL(string: "https://www.google.com/")!)
  ]

  var body: some View {
    Table(sampleData) {
      TableColumn("Title") { bookmark in
        Text(bookmark.title)
      }
      TableColumn("URL") { bookmark in
        Text(bookmark.url.absoluteString)
      }
    }
    .tableStyle(.bordered)
    .padding()
  }

}

行を選択可能にする

テーブル行を選択可能にする場合、

あたりを使う。selection引数の型がIDのオプショナルか、IDSet型かの違い。

struct ContentView: View {

  @State var sampleData: [Bookmark] = [
    Bookmark(title: "Genji App", url: URL(string: "https://genjiapp.com/")!),
    Bookmark(title: "Apple", url: URL(string: "https://www.apple.com/")!),
    Bookmark(title: "Google", url: URL(string: "https://www.google.com/")!)
  ]
  @State private var selectedID: Bookmark.ID?

  var body: some View {
    Table(sampleData, selection: $selectedID) {
      TableColumn("Title") { bookmark in
        Text(bookmark.title)
      }
      TableColumn("URL") { bookmark in
        Text(bookmark.url.absoluteString)
      }
    }
    .tableStyle(.bordered)
    .padding()
  }
}

行クリック時に選択可能になった。

ドラッグ&ドロップで並べ替え

テーブル行をドラッグ&ドロップで並べ替え可能にするには、init(of: Value.Type, selection: Binding<Set<Value.ID>>, columns: () -> Columns, rows: () -> Rows)あたりを使う。先ほどまでのイニシャライザとは使い方がちょっと違う。第一引数に表示するデータの型、最後のrows引数にはTableRowで表示データを供給する。以下のような感じ。

struct ContentView: View {

  @State var sampleData: [Bookmark] = [
    Bookmark(title: "Genji App", url: URL(string: "https://genjiapp.com/")!),
    Bookmark(title: "Apple", url: URL(string: "https://www.apple.com/")!),
    Bookmark(title: "Google", url: URL(string: "https://www.google.com/")!)
  ]
  @State private var selectedID: Bookmark.ID?

  var body: some View {
    Table(of: Bookmark.self, selection: $selectedID) {
      TableColumn("Title") { bookmark in
        Text(bookmark.title)
      }
      TableColumn("URL") { bookmark in
        Text(bookmark.url.absoluteString)
      }
    } rows: {
      ForEach(sampleData) { bookmark in
        TableRow(bookmark)
      }
    }
    .tableStyle(.bordered)
    .padding()
  }

}

ここまでは、まだデータの表示と行選択ができるだけで、前項までとは別のイニシャライザを使ったに過ぎない。行をドラッグ&ドロップできるようにするには、draggabledropDestinationモディファイアを使う。この時、表示データはTransferableプロトコルに適合させる必要があり、そのためにはCodableプロトコルにも適合させておくとかんたんになる。また、ドラッグ&ドロップさせるデータのためのカスタムUTTypeも用意する。以下のような感じ。

import SwiftUI
import UniformTypeIdentifiers

extension UTType {
  static let bookmark = UTType(exportedAs: "com.genjiapp.TableSample.bookmark")
}

struct Bookmark: Identifiable, Codable, Transferable {

  static var transferRepresentation: some TransferRepresentation {
    CodableRepresentation(contentType: .bookmark)
  }

  var id = UUID()
  var title: String
  var url: URL

}

struct ContentView: View {

  @State var sampleData: [Bookmark] = [
    Bookmark(title: "Genji App", url: URL(string: "https://genjiapp.com/")!),
    Bookmark(title: "Apple", url: URL(string: "https://www.apple.com/")!),
    Bookmark(title: "Google", url: URL(string: "https://www.google.com/")!)
  ]
  @State private var selectedID: Bookmark.ID?

  var body: some View {
    Table(of: Bookmark.self, selection: $selectedID) {
      TableColumn("Title") { bookmark in
        Text(bookmark.title)
      }
      TableColumn("URL") { bookmark in
        Text(bookmark.url.absoluteString)
      }
    } rows: {
      ForEach(sampleData) { bookmark in
        TableRow(bookmark)
          .draggable(bookmark)
      }
      .dropDestination(for: Bookmark.self) { insertionIndex, insertionBookmarks in
        if let bookmark = insertionBookmarks.first,
           let originalIndex = sampleData.firstIndex(where: { $0.id == bookmark.id }) {
          sampleData.insert(bookmark, at: insertionIndex)
          let removeIndex = (insertionIndex > originalIndex) ? originalIndex : originalIndex + 1
          sampleData.remove(at: removeIndex)
        }
      }
    }
    .tableStyle(.bordered)
    .padding()
  }

}

draggableTableRowに、dropDestinationTableRowを囲むForEachに対して指定する。

また、用意したカスタムUTTypeは、その識別子をInfo.plist内で宣言しておかなければならない。

2023年06月19日, 編集履歴

Mailto Interceptor ver. 1.4をリリース

Mailto Interceptor ver. 1.4をリリースしました。

mailto:リンクのクリック等によるメールアプリケーションの意図しない即時起動を抑制できるmacOSアプリケーションです。デフォルトのメールアプリケーションとしてMailto Interceptorを指定することで、mailto:リンクのクリック等があったときに、

といった動作をさせることができます。

ver. 1.4では、

を行いました。

今となってはmailto:リンクを見ることも少なくなってはきましたが、どうぞよろしくお願いします。

2023年03月18日, 編集履歴

SwiftUIの .opacity() と .onHover() は順番が重要

いつの頃からかは解らないが、少なくともmacOS 13.2.1のSwiftUIでは.opacity().onHover()の順番が重要になっている。

環境:

以下のようなSwiftUIビューがあったとする。

struct ContentView: View {

  @State private var isOn = false

  var body: some View {
    Toggle("Toggle", isOn: $isOn)
      .toggleStyle(.switch)
      .onHover { isHovering in
        print(isHovering)
      }
      .padding()
  }
}

Toggleスウィッチの領域にマウスポインタを持っていく(ホバーする)と、.onHover()が反応する。ここで.opacity()を付けてToggleに透明度を設定したいとする。

struct ContentView: View {

  @State private var isOn = false

  var body: some View {
    Toggle("Toggle", isOn: $isOn)
      .toggleStyle(.switch)
      .onHover { isHovering in
        print(isHovering)
      }
      .opacity(0.0)
      .padding()
  }
}

.onHover()の後ろに.opacity()を付与した場合、macOS 11では完全に透明にしても.onHover()は反応していたが、macOS 13では反応しなくなっていた。完全に透明にしたのがいけなかったのかと思い、.opacity(0.5)とかしてみると反応してくれる。なるほど。では、.opacity(0.3)なら? なぜか「Toggle」と書かれたラベル部分にマウスを乗せても反応せず、スウィッチ部分に乗せた場合にだけ反応する。.onHover()が反応する領域が変化するという変な挙動を示す。

struct ContentView: View {

  @State private var isOn = false

  var body: some View {
    Toggle("Toggle", isOn: $isOn)
      .toggleStyle(.switch)
      .opacity(0.0)
      .onHover { isHovering in
        print(isHovering)
      }
      .padding()
  }
}

のように.opacity()を先に記述すると、完全に透明にした場合でも.onHover()が反応するし、ラベル部分であってもちゃんと反応領域に含まれる。

というわけで、(少なくともmacOS 13.2.1では).opacity()が先、.onHover()が後という順番でないといけない。

2023年03月14日, 編集履歴

貧者のキーボードブリッジ

現在使用しているMacはMacBook Pro 15インチの2018年モデルで、これは悪名高きバタフライキーボードが採用されているものだ。曰く、打ち心地が悪い、不具合が発生しやすいといった声が聞こえてきていた。私自身は打ち心地は問題がなく、これまで不具合も出ていなかった。しかし使い始めて約5年、とうとう意図しない反復入力されてしまう不具合が発生するようになった。

バタフライキーボードの不具合に関してはAppleの無償修理プログラムがあるのだが、対象となるのが販売日より4年間ということで、ちょうど保証期間が切れた直後という間の悪さ。まだまだこのMacBook Proを使い続けたい意志はあるものの、他にバッテリーの状態も悪く、それらが同時に修理対象となると、費用が数万円かかることが予想される。それだけ出して有償修理するくらいなら新しいAppleシリコンMacが欲しいけれど、それこそ数十万円が必要になってくる。

そこで外付けキーボードを用意することにした。メルカリでAppleのMagic Keyboardがリーズナブルな金額で手に入ったので、それをMacBook Proのキーボード面に乗せるスタイルで運用する。問題になるのは、上に載せたMagic KeyboardによってMacBook Pro本体のキーボードが押されてしまうことである。

この問題を解消するために「キーボードブリッジ」という製品がある。ノートパソコンのキーボード面を覆うためのプレート状の製品である。そのプレートの上に外付けキーボードを置くという寸法である。バード電子というメーカから5,000円程度で販売されている。

ただ、結局のところ、板状のものでキーボード面を覆えれば良いわけである。ということで、

貧者のキーボードブリッジ。薄い段ボール板を敷いただけ pic.twitter.com/daVj640JWv

— Genji (@genji_tw) March 10, 2023

Amazonの梱包の中敷に使われていた薄い段ボール板を半分に切って、ダイソーで買ってきた滑り止めのゴム足を貼り付けたものをMacBook Proに乗せてみた。こんなものでもちゃんとキーボードブリッジとして機能している。名付けて「貧者のキーボードブリッジ」である。

ゴム足をつけているとはいえ、ほぼほぼMacBook Proのキーボード面に密着してしまっているので、放熱面では問題がある。強度面との兼ね合いがあるが、段ボールに穴を開けてしまってもいいかもしれない。

機能確認はできたので、改めて本家バード電子のキーボードブリッジの購入を考えるか、あるいは、アクリル板のサイズや加工方法を指定してお安く購入できるネットショップがあるようなので、そこで注文してみるのもいいかもしれない。

最後に、いちばんの問題はTouch IDが塞がれてしまったことである。購入したMagic KeyboardはTouch IDが付いていないタイプ。Touch ID付きのMagic Keyboardについては、Touch ID機能はIntel Macでは動作しないようなのでどうしようもない。Touch IDが使いたい時はキーボードブリッジをずらすしかない。