「わたしの家計簿」開発の続きです。ブラウザに依存するのはどうも使い勝手が悪いので、WebView を使える環境ではそれを使って動作するようにしたいところです。
まず Mac 版です。ビルドは普通に wxWebView を使えばできます。macOS 11 より前では、未対応の機能がいろいろあるので(window.print() が実装されていないなど)、macOS 11 以上であることを実行時に確認します。
static bool
shouldUseWebView(void)
{
int major;
wxGetOsVersion(&major, NULL, NULL);
if (major >= 11)
return true;
else
return false;
}
実行してみると、ファイルの書き出しができない、という問題点が判明しました。実は、WKWebView (macOS の WebKit を使った WebView 実装)では、ダウンロードは 11.3 以降の機能であり、しかも複雑なようです。
Stack Overflow
How to download files in wkwebview
Anybody please tell me how to download files in iOS wkwebview. I have created an iOS webview app. In the page I have loaded It has several download options but when I click download nothing happe...
ファイル保存ダイアログでファイルパスを受け取って、それに書き出しするのがよさそうです。しかし、ファイル保存ダイアログは TypeScript 側では出せないので、バックエンド側で出さないといけません。処理の流れはこのようになります。
- TypeScript がバックエンドにファイル保存ダイアログの要求を POST する。
- バックエンドが要求を受け取る。HTTPサーバはサブスレッドで動いているため、メインスレッドにイベントを投げて、ファイル保存ダイアログを要求する。
- バックエンドのメインスレッドがイベントを受け取り、ファイル保存ダイアログを出す。
- ファイル保存ダイアログが閉じられたら、指定されたファイルパス、または空文字列(ダイアログがキャンセルされた場合)をキューに入れて、サブスレッドに知らせる。
- サブスレッドはキューにデータが入ったことを検出して、TypeScript にレスポンスを返す。
- TypeScript がデータを受け取って、空文字列でなければ、ファイル保存を行う。
ここで問題になるのは、3 と 4 の間にどれだけ時間がかかるか、予測できないことです。TypeScript 側で普通の HTTP レスポンスを待っていると、タイムアウトになってしまいます。そこで、Server-sent Events を使うことにしました。
TypeScript 側は下のように実装しています。fetchVueRunner() 関数は、HTTP サーバに POST リクエストを投げるものです。戻り値の res は、普通は HTTP レスポンスなのですが、ここでは Server-sent Events のイベントストリームです。この関数 (vSaveDialog) は Promise を返します。この Promise は、イベントストリームからのイベントを読み出して、ファイルパス(または空文字列)をデータとして検出したら resolve します。
export async function vSaveDialog(options?: object): Promise<string> {
const res = await fetchVueRunner({ cmd: "saveDialog", options: options });
// res は event-stream
const reader = res.body?.getReader();
const decoder = new TextDecoder();
return new Promise<string>(async (resolve, reject) => {
if (reader !== undefined) {
while (true) {
const {done, value} = await reader.read();
console.log(`vSaveDialog: done=${done}, value=${value}\n`);
if (done)
break;
if (!value)
continue;
const lines = decoder.decode(value);
let [ type, text ] = lines.split(": ");
if (type === "data") {
// text may be an empty string (the save dialog was canceled)
text = text.trim();
console.log(`vSaveDialog: resolve(${text})\n`);
resolve(text);
return;
}
}
}
reject();
return;
});
}
バックエンド側の、HTTP サーバの処理です。独自の wxCommandEvent を作成して、メインスレッドに投げます。このとき、POST で渡されたデータ(json 型の j)にServer-sent Events のコネクションを指定する値(c->id)を追加して、イベント内のデータとして渡します。
if (cmd == "saveDialog") {
j["connection_id"] = c->id;
wxCommandEvent *anEvent = new wxCommandEvent(MyEvent);
anEvent->SetClientData(strdup(j.dump().c_str()));
wxGetApp().QueueEvent(anEvent);
mg_printf(c, sSSEResponse);
c->is_resp = 0;
return; // Early return: no standard http reply
}
イベントハンドラはこのような実装です(一部省略)。sSSEResults は、std::queue<std::pair<unsigned long, std::string>> 型の static 変数です。
void *d = event.GetClientData();
json j = json::parse((const char *)d);
free(d); // d must have been allocated by strdup()
std::string cmd = j["cmd"];
unsigned long id = j["connection_id"];
if (cmd == "saveDialog") {
/* ... */
wxFileDialog dialog(NULL,
wxString::FromUTF8(title.c_str()),
wxString::FromUTF8(dir.c_str()),
wxString::FromUTF8(defaultPath.c_str()),
wxString::FromUTF8(wildcard.c_str()),
wxFD_SAVE | wxFD_OVERWRITE_PROMPT | wxFD_CHANGE_DIR);
if (dialog.ShowModal() == wxID_OK) {
result = (const char *)(dialog.GetPath().mb_str(wxConvFile));
}
{ // Push the result to the queue
std::lock_guard<std::mutex> lock(sMutex);
sSSEResults.push(std::make_pair(id, result));
}
}
HTTP サーバのスレッドでは、入力待ちのループの中に下のような処理を入れて、キューにデータが入っていたらそれを送ります。データを送ったら、Server-sent Events のコネクションを切断します。
std::pair<unsigned long, std::string> pair;
{
std::lock_guard<std::mutex> lock(sMutex);
if (sSSEResults.empty())
break;
pair = std::move(sSSEResults.front());
sSSEResults.pop();
}
mg_connection *c = mgr.conns;
while (c != NULL) {
if (c->id == pair.first)
break;
c = c->next;
}
if (c != NULL) {
mg_printf(c, "data: %s\n\n", pair.second.c_str());
c->is_draining = 1;
}
ちゃんと動作しました!
長くなったので、Windows 版のビルドは後日書きます。