From a93af6be36a0a7b16c141aeb7c7fa6b8a3463694 Mon Sep 17 00:00:00 2001
From: kumagallium
Date: Fri, 28 Aug 2026 13:46:26 +0900
Subject: [PATCH] [fix] Relaunch through launchd so an update keeps folder
access
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Updating on macOS could leave the app unable to read ~/Documents on its
first launch, showing "the notes folder could not be opened" with
`Operation not permitted (os error 1)`. Quitting and opening the app again
cleared it, which is the tell: the permission itself was intact.
The updater called the process plugin's relaunch(), which spawns the new
binary from the current process. On macOS a child inherits its parent's TCC
responsible process, so the app the updater had just replaced handed down
an identity that no longer resolved, and the folder was denied.
relaunch_via_launchd hands the launch to `open` instead, so launchd becomes
responsible and the replaced bundle is evaluated on its own signature. It
waits for the current process to exit before calling `open`, which would
otherwise only focus the running app, and closes the main window rather
than calling exit() so the sidecar still gets its shutdown path.
The startup screen was pointing at System Settings, where the entry may not
even exist in this state — sending people to hunt through a settings pane
for a toggle that is not there. It now offers Restart Graphium first and
keeps the settings route as the second try. The manual follows in both
languages.
Co-Authored-By: Claude Opus 5
---
docs/ARCHITECTURE.md | 15 +++++++
manual/desktop-app.md | 2 +-
manual/ja/desktop-app.md | 2 +-
src-tauri/src/lib.rs | 75 ++++++++++++++++++++++++++++++++++
src/i18n/en.ts | 6 ++-
src/i18n/ja.ts | 4 +-
src/lib/relaunch.test.ts | 57 ++++++++++++++++++++++++++
src/lib/relaunch.ts | 33 +++++++++++++++
src/lib/storage/use-storage.ts | 5 ++-
src/lib/updater.ts | 9 ++--
src/note-app.tsx | 40 ++++++++++++++----
11 files changed, 229 insertions(+), 19 deletions(-)
create mode 100644 src/lib/relaunch.test.ts
create mode 100644 src/lib/relaunch.ts
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index b758d10f1..5b3158f0c 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -1014,6 +1014,21 @@ The same `src/` tree is built four different ways.
raises no event the frontend can see, so `printAndWait` resolves as soon
as the panel is open and the print tree is left in the DOM — it sits
off-screen and is discarded at the start of the next print
+- Relaunching after an update goes through the `relaunch_via_launchd`
+ command rather than the process plugin's `relaunch()`. `relaunch()`
+ spawns the new binary from the current process, and on macOS a child
+ inherits its parent's TCC responsible process — so the app the updater
+ has just replaced hands down an identity that no longer resolves, and
+ the first launch after an update is denied `~/Documents` with `EPERM`
+ ("the notes folder will not open right after updating, but opening the
+ app again fixes it"). The command hands the launch to `open` instead, so
+ launchd becomes responsible and the replaced bundle's signature is
+ evaluated afresh. `open` only focuses an app that is already running, so
+ it waits for the current process to exit first; and the app closes its
+ main window rather than calling `app.exit(0)`, keeping the normal
+ shutdown path (frontend stops the sidecar, then `shutdown_ack`) intact.
+ The same command backs the **Restart Graphium** button on the startup
+ failure screen. Non-macOS and non-bundled runs fall back to `relaunch()`
- AI / Knowledge features run inside the app via a Node sidecar:
`scripts/fetch-node.mjs` downloads Node 22 and renames it to
`binaries/graphium-server-[.exe]` so Tauri can spawn it as a
diff --git a/manual/desktop-app.md b/manual/desktop-app.md
index 6f4ade13f..d4f378b86 100644
--- a/manual/desktop-app.md
+++ b/manual/desktop-app.md
@@ -112,7 +112,7 @@ This is expected — the Windows build is unsigned. Click **More info**, then **
If Graphium stops on a screen saying it could not open your notes folder, press **Show details** — it names what actually failed, and the fix follows from that:
-- **macOS is blocking access.** Open System Settings → Privacy & Security → Files and Folders, turn on the Documents folder for Graphium, then press **Reload**. This can surface right after an update, because the replaced app may be asked to confirm access again.
+- **macOS is withholding access.** This almost always shows up right after an update: the replaced app starts while still carrying the permissions of the version it replaced, and launching it again clears it. Press **Restart Graphium** on that screen, or quit Graphium completely (`⌘Q`) and open it again. If the same screen comes back, open System Settings → Privacy & Security → Files and Folders, turn on the Documents folder for Graphium, then press **Reload**.
- **It is taking longer than usual.** The folder did not answer in time. Press **Reload** — the second attempt usually goes through.
- **The folder was not found.** If you moved the save location to an external drive or a synced folder via Settings → **Storage**, check that it is mounted and available.
diff --git a/manual/ja/desktop-app.md b/manual/ja/desktop-app.md
index a2601604a..451736867 100644
--- a/manual/ja/desktop-app.md
+++ b/manual/ja/desktop-app.md
@@ -112,7 +112,7 @@ Windows 版は署名されていないため、これは想定どおりです。
ノートフォルダを開けなかったという画面で止まる場合は、**詳細を表示** を押してください。実際に何で失敗したかが出るので、それに応じて対処します。
-- **macOS がアクセスを保留している。** システム設定 → プライバシーとセキュリティ → ファイルとフォルダ を開き、Graphium の「書類」フォルダをオンにしてから **再読み込み** を押します。更新の直後に出ることがあります(差し替わったアプリが改めて確認を求めるため)。
+- **macOS がアクセスを保留している。** 更新の直後に出ることがほとんどです。差し替わったアプリが、更新前のアプリの権限を引きずったまま起動してしまうためで、起動し直せば通ります。画面の **アプリを再起動** を押すか、Graphium を完全に終了して(`⌘Q`)開き直してください。同じ画面が戻ってくるときは、システム設定 → プライバシーとセキュリティ → ファイルとフォルダ を開き、Graphium の「書類」フォルダをオンにしてから **再読み込み** を押します。
- **時間がかかっている。** フォルダの応答が間に合わなかった状態です。**再読み込み** を押すと、たいてい 2 回目で通ります。
- **フォルダが見つからない。** 設定 → **ストレージ** で外付けドライブや同期フォルダに保存先を移している場合は、それが使える状態か確認してください。
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index d68d211fe..799396cf8 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -1789,6 +1789,80 @@ fn print_webview(window: tauri::WebviewWindow) -> Result<(), String> {
.map_err(|e| format!("印刷パネルを開けません: {e}"));
}
+/// 実行中の `.app` バンドルのパスを返す(macOS)。
+/// `cargo run` のようにバンドル化されていない起動では解決できないので Err を返す。
+#[cfg(target_os = "macos")]
+fn current_bundle_path() -> Result {
+ let exe = std::env::current_exe().map_err(|e| format!("実行ファイルのパス取得に失敗: {e}"))?;
+ // .app/Contents/MacOS/ なので 3 つ上が .app
+ let bundle = exe
+ .parent()
+ .and_then(|p| p.parent())
+ .and_then(|p| p.parent())
+ .ok_or("バンドルのパスを解決できません")?;
+ if bundle.extension().and_then(|e| e.to_str()) != Some("app") {
+ return Err("バンドルとして起動していません".to_string());
+ }
+ Ok(bundle.to_path_buf())
+}
+
+/// アプリを launchd 経由で起動し直す(macOS 専用)。
+///
+/// Tauri の `relaunch()` は今のプロセスから新しいバイナリを spawn する。平常時は
+/// それで足りるが、アップデータが `.app` を丸ごと置き換えた直後だけ事情が変わる。
+/// macOS の TCC は子プロセスに親の責任プロセス(responsible process)を継がせる
+/// ので、置き換え済みで解決できなくなったバンドルの identity を背負ったまま起動し、
+/// 書類フォルダが `Operation not permitted` で弾かれる。ユーザーから見ると
+/// 「更新した直後だけノートが開けない、一度閉じて開き直すと直る」という形で出る。
+///
+/// `open` に任せると launchd が責任プロセスになり、置き換え後のバンドルの署名で
+/// 評価し直される。ただし `open` は起動中のアプリを前面に出すだけなので、今の
+/// プロセスが消えるのを待ってから叩く必要がある。待ちは別プロセスに任せる。
+///
+/// 終了は `app.exit(0)` ではなくウィンドウを閉じて既存の終了経路に乗せる。
+/// sidecar の後始末はフロントが `app-close-requested` を受けてから `shutdown_ack`
+/// を返すまでの間に走るので、そこを飛ばすと sidecar が残ってポートを掴んだままになる。
+#[tauri::command]
+fn relaunch_via_launchd(app: tauri::AppHandle) -> Result<(), String> {
+ #[cfg(target_os = "macos")]
+ {
+ let bundle = current_bundle_path()?;
+ // パスにスペースや引用符が入っていても壊れないようシングルクォートで包む
+ let quoted = format!("'{}'", bundle.to_string_lossy().replace('\'', r"'\''"));
+ let pid = std::process::id();
+ // 自分が消えるまで最大 10 秒待ってから open する。待ち切っても open は
+ // 投げる(前面に出るだけで害はない)。
+ let script = format!(
+ "for _ in $(seq 1 100); do kill -0 {pid} 2>/dev/null || break; sleep 0.1; done; \
+ /usr/bin/open -a {quoted}"
+ );
+ Command::new("/bin/sh")
+ .arg("-c")
+ .arg(script)
+ .stdin(Stdio::null())
+ .stdout(Stdio::null())
+ .stderr(Stdio::null())
+ .spawn()
+ .map_err(|e| format!("再起動コマンドの起動に失敗: {e}"))?;
+
+ // 既存の終了経路(CloseRequested → sidecar 停止 → shutdown_ack)に乗せる。
+ // ウィンドウが取れないときだけ直接落とす。
+ if let Some(window) = app.get_webview_window("main") {
+ window
+ .close()
+ .map_err(|e| format!("ウィンドウを閉じられません: {e}"))?;
+ } else {
+ app.exit(0);
+ }
+ Ok(())
+ }
+ #[cfg(not(target_os = "macos"))]
+ {
+ let _ = app;
+ Err("launchd 経由の再起動は macOS 専用です".to_string())
+ }
+}
+
/// フロント側で生成したファイル(PDF / PROV-JSON-LD / Markdown / zip)を
/// ネイティブの保存ダイアログ経由でディスクに保存する。
///
@@ -1903,6 +1977,7 @@ pub fn run() {
kill_pid,
save_bytes_with_dialog,
print_webview,
+ relaunch_via_launchd,
start_native_sidecar,
stop_native_sidecar,
])
diff --git a/src/i18n/en.ts b/src/i18n/en.ts
index dac3baf97..722297439 100644
--- a/src/i18n/en.ts
+++ b/src/i18n/en.ts
@@ -69,10 +69,12 @@ export const en: Record = {
"common.loading": "Loading...",
"startup.initFailed": "Graphium could not read your notes folder. Reloading usually clears it.",
"startup.reload": "Reload",
- "startup.initFailedPermission": "Graphium could not open your notes folder — macOS is blocking access until you allow it.",
+ "startup.initFailedPermission": "Graphium could not open your notes folder — macOS is withholding access.",
"startup.initFailedTimeout": "Reading your notes folder is taking longer than usual. Reloading usually clears it.",
"startup.initFailedMissing": "Graphium could not find your notes folder. If you keep it on an external drive or a synced folder, check that it is available.",
- "startup.folderAccessHint": "Open System Settings → Privacy & Security → Files and Folders, turn on the Documents folder for Graphium, then reload.",
+ "startup.folderAccessHint": "Right after an update, quitting Graphium and opening it again usually clears this.",
+ "startup.folderAccessHintSettings": "If it still will not open, turn on the Documents folder for Graphium in System Settings → Privacy & Security → Files and Folders.",
+ "startup.restartApp": "Restart Graphium",
"startup.errorDetailShow": "Show details",
"startup.errorDetailHide": "Hide details",
"common.table": "[Table]",
diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts
index ed0840d29..657187918 100644
--- a/src/i18n/ja.ts
+++ b/src/i18n/ja.ts
@@ -72,7 +72,9 @@ export const ja: Record = {
"startup.initFailedPermission": "ノートフォルダを開けませんでした。macOS がアクセスを保留しているようです。",
"startup.initFailedTimeout": "ノートフォルダの読み込みに時間がかかっています。再読み込みで直ることがほとんどです。",
"startup.initFailedMissing": "ノートフォルダが見つかりませんでした。外付けドライブや同期フォルダに置いている場合は、使える状態か確認してください。",
- "startup.folderAccessHint": "システム設定 → プライバシーとセキュリティ → ファイルとフォルダ を開き、Graphium の「書類」フォルダをオンにしてから再読み込みしてください。",
+ "startup.folderAccessHint": "アップデートした直後は、一度アプリを終了して開き直すと通ることがあります。",
+ "startup.folderAccessHintSettings": "それでも開けないときは、システム設定 → プライバシーとセキュリティ → ファイルとフォルダ で Graphium の「書類」フォルダをオンにしてください。",
+ "startup.restartApp": "アプリを再起動",
"startup.errorDetailShow": "詳細を表示",
"startup.errorDetailHide": "詳細を隠す",
"common.table": "[テーブル]",
diff --git a/src/lib/relaunch.test.ts b/src/lib/relaunch.test.ts
new file mode 100644
index 000000000..389fdb4dc
--- /dev/null
+++ b/src/lib/relaunch.test.ts
@@ -0,0 +1,57 @@
+// @vitest-environment jsdom
+// relaunchApp のテスト。
+// 要点は「macOS で relaunch() を直に呼ばないこと」。ここが崩れると、アップデート
+// 直後の初回起動だけ書類フォルダが読めなくなる回帰に戻る(relaunch.ts のコメント参照)。
+
+import { describe, it, expect, beforeEach, vi } from "vitest";
+
+const invokeMock = vi.hoisted(() => vi.fn());
+const relaunchMock = vi.hoisted(() => vi.fn());
+const isTauriMock = vi.hoisted(() => vi.fn());
+
+vi.mock("@tauri-apps/api/core", () => ({ invoke: invokeMock }));
+vi.mock("@tauri-apps/plugin-process", () => ({ relaunch: relaunchMock }));
+vi.mock("./platform", () => ({ isTauri: isTauriMock }));
+
+import { relaunchApp } from "./relaunch";
+
+describe("relaunchApp", () => {
+ beforeEach(() => {
+ invokeMock.mockReset();
+ relaunchMock.mockReset();
+ isTauriMock.mockReset();
+ isTauriMock.mockReturnValue(true);
+ vi.spyOn(console, "warn").mockImplementation(() => {});
+ });
+
+ it("デスクトップでは launchd 経由のコマンドを呼び、relaunch() には落ちない", async () => {
+ invokeMock.mockResolvedValue(undefined);
+
+ await relaunchApp();
+
+ expect(invokeMock).toHaveBeenCalledWith("relaunch_via_launchd");
+ expect(relaunchMock).not.toHaveBeenCalled();
+ });
+
+ it("macOS 以外やバンドル外実行で失敗したら relaunch() に切り替える", async () => {
+ invokeMock.mockRejectedValue("launchd 経由の再起動は macOS 専用です");
+
+ await relaunchApp();
+
+ expect(invokeMock).toHaveBeenCalledWith("relaunch_via_launchd");
+ expect(relaunchMock).toHaveBeenCalledTimes(1);
+ });
+
+ it("Web 版では Tauri のコマンドを呼ばずリロードする", async () => {
+ isTauriMock.mockReturnValue(false);
+ const reload = vi.fn();
+ vi.stubGlobal("location", { ...window.location, reload });
+
+ await relaunchApp();
+
+ expect(invokeMock).not.toHaveBeenCalled();
+ expect(relaunchMock).not.toHaveBeenCalled();
+ expect(reload).toHaveBeenCalledTimes(1);
+ vi.unstubAllGlobals();
+ });
+});
diff --git a/src/lib/relaunch.ts b/src/lib/relaunch.ts
new file mode 100644
index 000000000..0de0cce81
--- /dev/null
+++ b/src/lib/relaunch.ts
@@ -0,0 +1,33 @@
+// アプリの再起動(デスクトップ版のみ)
+
+import { invoke } from "@tauri-apps/api/core";
+import { isTauri } from "./platform";
+
+/**
+ * アプリを起動し直す。
+ *
+ * macOS では Tauri の `relaunch()` を直接は使わない。`relaunch()` は今のプロセスから
+ * 新しいバイナリを spawn するので、アップデータが `.app` を置き換えた直後だけ、
+ * TCC の責任プロセス(responsible process)が置き換え前のバンドルに紐付いたまま
+ * 起動してしまう。解決できない identity として扱われ、書類フォルダが
+ * `Operation not permitted` で弾かれる ── 「更新した直後だけノートが開けない、
+ * 一度閉じて開き直すと直る」の正体がこれ。
+ *
+ * Rust 側の `relaunch_via_launchd` は `open` に起動を任せるので、launchd が
+ * 責任プロセスになり、置き換え後のバンドルの署名で評価し直される。
+ * macOS 以外やバンドル外実行では Err が返るので、そこは従来どおり `relaunch()`。
+ */
+export async function relaunchApp(): Promise {
+ if (!isTauri()) {
+ window.location.reload();
+ return;
+ }
+ try {
+ await invoke("relaunch_via_launchd");
+ return;
+ } catch (e) {
+ console.warn("[relaunch] launchd 経由に失敗、relaunch() に切り替えます:", e);
+ }
+ const { relaunch } = await import("@tauri-apps/plugin-process");
+ await relaunch();
+}
diff --git a/src/lib/storage/use-storage.ts b/src/lib/storage/use-storage.ts
index f435bf255..795dcff8f 100644
--- a/src/lib/storage/use-storage.ts
+++ b/src/lib/storage/use-storage.ts
@@ -139,8 +139,9 @@ export function useStorage() {
`ストレージ初期化 試行 ${attempt}/${attempts} 失敗 [${failure.key}]:`,
failure.raw,
);
- // 権限で弾かれている場合、同じ呼び出しは何度やっても同じところで返る。
- // 粘らずに案内へ回し、ユーザーが許可してから再読み込みしてもらう。
+ // 権限で弾かれている場合、同じプロセスの中では何度やっても同じところで
+ // 返る(TCC の判定はプロセス単位で決まる)。粘らずに案内へ回し、
+ // 起動し直してもらう。
if (failure.needsFolderAccess) break;
if (attempt < attempts && !cancelled) await delay(INIT_RETRY_DELAY_MS);
}
diff --git a/src/lib/updater.ts b/src/lib/updater.ts
index 4b15d41d2..b5013d266 100644
--- a/src/lib/updater.ts
+++ b/src/lib/updater.ts
@@ -298,9 +298,12 @@ export async function checkForUpdates(): Promise {
onProgress({ phase: "installing" });
await update.install();
// Windows では install() から戻らない(インストーラ起動と同時に
- // プロセスが exit(0) で終了する)ため、relaunch は実質 macOS 用。
- const { relaunch } = await import("@tauri-apps/plugin-process");
- await relaunch();
+ // プロセスが exit(0) で終了する)ため、ここは実質 macOS 用。
+ // その macOS では relaunch() を直に呼ばない。置き換え直後のバンドルから
+ // spawn すると TCC の責任プロセスが更新前のバンドルに紐付いたままになり、
+ // 初回起動だけ書類フォルダが読めなくなる(relaunchApp のコメント参照)。
+ const { relaunchApp } = await import("./relaunch");
+ await relaunchApp();
},
};
window.dispatchEvent(
diff --git a/src/note-app.tsx b/src/note-app.tsx
index d922c054d..2b0678b61 100644
--- a/src/note-app.tsx
+++ b/src/note-app.tsx
@@ -4,6 +4,7 @@
import { Component, useCallback, useEffect, useMemo, useRef, useState, type ErrorInfo, type ReactNode } from "react";
import { Save, FileDown, Share2, MoreHorizontal, Network, GitBranch, Bot, History, FileText, PanelLeftOpen, BookPlus, BookOpen, Trash2, Archive, ArchiveRestore, StickyNote, Link2, Check, Pin, MoveHorizontal } from "lucide-react";
import { apiBase, isTauri, tauriDetectionDetail } from "./lib/platform";
+import { relaunchApp } from "./lib/relaunch";
import { onMenuAction } from "./lib/menu-events";
import { ensureSidecar, getSidecarState, subscribeSidecarState } from "./lib/sidecar";
import { SandboxEditor } from "./base/editor";
@@ -5341,6 +5342,12 @@ class ListSidePeekBoundary extends Component<
* 「読み込めませんでした」だけでは、権限で弾かれたのか・応答が返らなかったのか・
* 保存先が消えたのかが誰にも分からない。分類した説明を主役に置き、OS の許可が
* 要るケースだけ手順を添え、生のエラーは問い合わせ用に畳んでおく。
+ *
+ * macOS で権限に弾かれるケースの大半は、アップデータが `.app` を置き換えた直後の
+ * 初回起動で TCC の責任プロセスが更新前のバンドルに紐付いたまま残っているもので、
+ * 起動し直せば通る(詳細は lib/relaunch.ts)。システム設定を触る必要は無いのに
+ * そちらへ案内すると、エントリが無い設定画面を探し回らせることになる。
+ * 起動し直す導線を先に出し、システム設定は効かなかったときの二の手に置く。
*/
function StartupInitFailureScreen({ failure }: { failure: StorageInitFailure | null }) {
const t = useT();
@@ -5353,16 +5360,31 @@ function StartupInitFailureScreen({ failure }: { failure: StorageInitFailure | n
{t(messageKey)}