Open6
Tauri v2のSidecarでPythonを使う

基本この通りにやればいい

shellプラグインが必要
$ yarn run tauri add shell

PyInstallerで実行したいPythonファイルをバイナリ化する
$ pip install pyinstaller
$ pyinstaller --onefile --name my-sidecar double.py
Intel MacとAppleシリコンMacはMacから作成できるが、Windows向けexeはWindows機で実行する必要あり
それぞれファイル名にターゲットをつけて指定すると勝手に適切なものを読んでくれるらしい

バイナリへの引数の受け渡しは
権限ファイルに指定する。任意の文字列の引数を渡したいなら
{
"identifier": "shell:allow-execute",
"allow": [
{
"args": [
{
"validator": "\\S+"
},
{
"validator": "\\S+"
}
],
"name": "binaries/sidecar-test",
"sidecar": true
}
]
},
って書いて、
TypeScript側で呼び出すなら
const command = Command.sidecar('binaries/sidecar-test', [ "arg1", "arg2" ]);
const output = await command.execute();
Rustなら
use tauri_plugin_shell::ShellExt;
#[tauri::command]
async fn call_sidecar(app: tauri::AppHandle) {
let sidecar_command = app
.shell()
.sidecar("sidecar-test")
.unwrap()
.args(["arg1", "arg2"]);
let (mut _rx, mut _child) = sidecar_command.spawn().unwrap();
}

Tauri -> Pythonのファイル受け渡し
(画像ファイル)
- Base64をそのままargで渡すと大きなファイルのときに長すぎてメモリ使いすぎになる
たぶん一時ファイルにするのがいい
import { BaseDirectory, tempDir } from "@tauri-apps/api/path";
import { writeTextFile } from "@tauri-apps/plugin-fs";
import { Command } from "@tauri-apps/plugin-shell";
async function runPython(base64: string) {
const filename = `image_${Date.now()}.b64`;
try {
await writeTextFile(filename, base64, { baseDir: BaseDirectory.Temp });
const tempDirPath = await tempDir();
const fullPath = `${tempDirPath}${filename}`;
const command = Command.sidecar('binaries/sidecar-test', [fullPath]);
return await command.execute();
} catch (error) {
throw error;
} finally {
await remove(filename, { baseDir: BaseDirectory.Temp });
}
}
/** component **/
const handleSendImage = async () => {
setLoading(true);
try {
const response = await runPython(base64);
console.log(response.stdout);
} catch (error) {
console.error(error);
} finally {
setLoading(false);
}
};
capabilities
{
"identifier": "shell:allow-execute",
"allow": [
{
"args": [
{
"validator": "\\S+"
}
],
"name": "binaries/sidecar-test",
"sidecar": true
}
]
},
{
"identifier": "fs:allow-write-text-file",
"allow": [{ "path": "$TEMP/*" }]
},
{
"identifier": "fs:allow-remove",
"allow": [{ "path": "$TEMP/*" }]
}
pythonからは普通にsys.argv[1]にfile pathが渡されるのでそれを読めばいい。

Pythonの実行が遅い
- ライブラリのインポートが遅い。毎回execute()すると処理が遅すぎる。
- PythonでHTTPサーバーを立てて、Tauriマウント時に起動して常駐させておけばすぐ応答するのでは?
- →うまくいった!爆速になった。ビルドしてもちゃんと動くのを確認済み(windowsはまだ未確認)
- (これも一時ファイル作ってファイルパス渡したほうがいいのかも)
usePythonServer.ts
import { listen } from "@tauri-apps/api/event";
import { Child, Command } from "@tauri-apps/plugin-shell";
import { useEffect, useRef, useState } from "react";
type ServerStatus =
| { state: 'loading' }
| { state: 'ready' }
| { state: 'error'; message: string };
const usePythonServer = () => {
const [serverStatus, setServerStatus] = useState<ServerStatus>({ state: 'loading' });
const processRef = useRef<Child | null>(null);
const isShuttingDownRef = useRef<boolean>(false);
const waitForServer = async () => {
for (let i = 0; i < 50; i++) {
try {
const response = await fetch("http://127.0.0.1:5001/health");
if (response.ok) return;
} catch (error) {
console.warn("サーバー起動中:", error);
}
await new Promise(resolve => setTimeout(resolve, 400));
}
throw new Error("サーバー起動タイムアウト: ポート5001が既に使用されている可能性があります");
};
useEffect(() => {
let closeUnlisten: (() => void) | null = null;
let destroyedUnlisten: (() => void) | null = null;
const startServer = async () => {
const command = Command.sidecar('binaries/sidecar-test');
try {
const process = await command.spawn();
processRef.current = process;
await waitForServer();
setServerStatus({ state: 'ready' });
} catch (error) {
console.error("サーバー起動エラー:", error);
setServerStatus({ state: 'error', message: `起動エラー: ${error}` });
}
};
const shutdownServer = async () => {
if (isShuttingDownRef.current) return;
isShuttingDownRef.current = true;
try {
await fetch("http://127.0.0.1:5001/shutdown", {
method: "POST",
headers: { "Content-Type": "application/json" }
});
} catch (error) {
console.error("シャットダウンエラー:", error);
}
};
const setupListeners = async () => {
closeUnlisten = await listen('tauri://close-requested', shutdownServer);
destroyedUnlisten = await listen('tauri://destroyed', shutdownServer);
};
startServer();
setupListeners();
return () => {
closeUnlisten?.();
destroyedUnlisten?.();
};
}, []);
const runPython = async (base64: string) => {
const response = await fetch("http://127.0.0.1:5001/process", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ image: base64 }),
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return await response.json();
};
return {
serverStatus,
runPython,
};
};
export default usePythonServer;
python
import base64
import io
import os
import signal
import socket
import sys
import threading
from flask import Flask, request, jsonify
from flask_cors import CORS
from PIL import Image
class ImageServer:
def __init__(self, host="127.0.0.1", port=5001):
self.host = host
self.port = port
self.shutdown_requested = False
self.app = self._create_app()
def _create_app(self):
app = Flask(__name__)
CORS(app,
origins=["http://localhost:1420", "https://tauri.localhost", "tauri://localhost"],
methods=["GET", "POST", "OPTIONS"],
allow_headers=["Content-Type"],
supports_credentials=True
)
app.add_url_rule("/health", "health", self.health, methods=["GET"])
app.add_url_rule("/shutdown", "shutdown", self.shutdown, methods=["POST"])
app.add_url_rule("/process", "process", self.process, methods=["POST"])
return app
def health(self):
return jsonify({"status": "ok"})
def shutdown(self):
if self.shutdown_requested:
return jsonify({"status": "already_shutting_down", "message": "シャットダウン中です"})
self.shutdown_requested = True
threading.Timer(0.1, lambda: os._exit(0)).start()
return jsonify({"status": "shutting_down", "message": "サーバーを終了しています..."})
def process(self):
if self.shutdown_requested:
return jsonify({"status": "error", "message": "サーバーが終了しています"}), 503
try:
image_b64 = request.json["image"]
image_data = base64.b64decode(image_b64)
image = Image.open(io.BytesIO(image_data))
width, height = image.size
file_size = len(image_data)
return jsonify({
"status": "ok",
"result": "processed",
"image_info": {
"width": width,
"height": height,
"file_size_bytes": file_size,
"format": image.format
}
})
except Exception as e:
return jsonify({"status": "error", "message": str(e)}), 400
def is_port_available(self):
try:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind((self.host, self.port))
return True
except OSError:
return False
def setup_signal_handlers(self):
def signal_handler(signum):
print(f"シグナル {signum} を受信。終了します...", file=sys.stderr)
self.shutdown_requested = True
os._exit(0)
signal.signal(signal.SIGTERM, signal_handler)
signal.signal(signal.SIGINT, signal_handler)
def run(self):
if not self.is_port_available():
print(f"エラー: ポート {self.port} は既に使用されています。", file=sys.stderr)
sys.exit(1)
self.setup_signal_handlers()
print(f"Python Flask サーバーを {self.host}:{self.port} で起動します...", file=sys.stderr)
try:
self.app.run(host=self.host, port=self.port, debug=False, threaded=True)
except KeyboardInterrupt:
print("キーボード割り込みで終了します...", file=sys.stderr)
sys.exit(0)
except Exception as e:
print(f"サーバー起動エラー: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
server = ImageServer()
server.run()
capabilities
{
"identifier": "shell:allow-spawn",
"allow": [
{
"name": "binaries/sidecar-test",
"sidecar": true
}
]
},
{
"identifier": "shell:allow-kill",
"allow": [
{
"name": "binaries/sidecar-test",
"sidecar": true
}
]
},
"core:window:allow-destroy",
"core:window:allow-close",
{
"identifier": "fs:allow-write-text-file",
"allow": [{ "path": "$TEMP/*" }]
},
{
"identifier": "fs:allow-remove",
"allow": [{ "path": "$TEMP/*" }]
}