インターネット接続状態(オンライン)を監視する

navigator.onLine は true でもネットに出られるとは限らない。online / offline イベントと、http プラグインの fetch や Rust の reqwest で 204 が返るかの確認を組み合わせる。

通信 対象: Tauri 2.x 更新日: 読了目安: 約10分 net-012
目次
  1. 前提条件
  2. 1. フロントエンドから実装する (TypeScript)
  3. navigator.onLine の限界
  4. 実際に届くかを確かめる
  5. 2 つを組み合わせて監視する
  6. 2. バックエンドから実装する (Rust)
  7. 動作確認
  8. よくあるエラーと対処法
  9. OS ごとの違いと注意点
  10. 関連レシピ

オフラインの表示を出す、同期を止めて再開する、WebSocket や SSE を繋ぎ直す、といった処理のきっかけとして接続状態を監視します。手軽なのは Webview の navigator.onLine ですが、これは「ネットワークに繋がっているか」を見るだけで、インターネットやアプリのサーバーに届くかまでは分かりません。その限界と、実際にリクエストを送って確かめる方法を JS と Rust の両方で示します。

前提条件

navigator.onLine と online / offline イベントは追加設定なしで使えます。届くかどうかを JS から確かめる場合は http プラグイン(npm run tauri add http)を使い、確認先の URL を許可します。許可の書き方は HTTP GET リクエストを送る(Rust経由) を参照してください。

{
  "$schema": "../gen/schemas/desktop-schema.json",
  "identifier": "default",
  "description": "Capability for the main window",
  "windows": ["main"],
  "permissions": [
    "core:default",
    {
      "identifier": "http:default",
      "allow": [{ "url": "https://www.gstatic.com/generate_204" }]
    }
  ]
}

Rust で確かめる場合は、http プラグインの代わりに src-tauri で cargo add reqwest@0.12 と cargo add tokio --features time を実行します。

1. フロントエンドから実装する (TypeScript)

navigator.onLine の限界

navigator.onLine が false なら、ネットワークアダプターがどこにも繋がっていないので、オフラインと判断してかまいません。一方 true は次のような状態でも返ります。

  • Wi-Fi には繋がっているが、ルーターの先の回線が切れている
  • ホテルや駅の Wi-Fi で、ログイン画面(キャプティブポータル)を通っていない
  • VPN や仮想化ソフトの仮想ネットワークアダプターが「接続中」のまま残っている
  • ファイアウォールやプロキシで、アプリのサーバーへの通信だけが止められている

つまり false は信じてよく、true は疑うものです。online / offline イベントも、この判定が変わったときに発生します。

// ネットワークアダプターの状態の変化を受け取る(プラグイン・権限は不要)
export function watchNavigatorOnline(render: (online: boolean) => void) {
  const update = () => render(navigator.onLine);
  window.addEventListener('online', update);
  window.addEventListener('offline', update);
  update(); // 起動時の状態も反映する
  return () => {
    window.removeEventListener('online', update);
    window.removeEventListener('offline', update);
  };
}

実際に届くかを確かめる

確かめる相手は「インターネット」ではなく「アプリが使うサーバー」です。自分のサーバーに、本文なしで 204 を返すだけの URL(例: /healthz)を用意するのが確実です。以下の例では、同じ用途で公開されている https://www.gstatic.com/generate_204 を使います。状態コードが 204 かどうかまで見るのは、キャプティブポータルが 200 のログイン画面やリダイレクトを返すためで、リダイレクトを追わないよう maxRedirections: 0 も付けます。

標準の fetch で確かめると、CORS を許可していない URL では必ず失敗し、「常にオフライン」と誤判定します。http プラグインの fetch なら CORS の影響を受けません。

import { fetch } from '@tauri-apps/plugin-http';

// 自分のサーバーに用意した「204 を返すだけの URL」にするのが確実
const CHECK_URL = 'https://www.gstatic.com/generate_204';

export async function checkReachable(timeoutMs = 5000): Promise<boolean> {
  const controller = new AbortController();
  const timer = window.setTimeout(() => controller.abort(), timeoutMs);
  try {
    const res = await fetch(CHECK_URL, {
      signal: controller.signal,
      connectTimeout: timeoutMs,
      maxRedirections: 0, // ログイン画面へのリダイレクトを追わない
    });
    return res.status === 204; // 200 のログイン画面や 302 は「届いていない」扱い
  } catch {
    return false; // 名前解決の失敗・接続拒否・タイムアウト
  } finally {
    window.clearTimeout(timer);
  }
}

2 つを組み合わせて監視する

offline イベントはすぐに反映し、online イベントでは確かめてから戻します。オンラインの間も定期的に確かめますが、スリープ復帰の直後などは一時的に失敗しやすいので、1 回の失敗ではオフラインにしません。

// (続き) navigator.onLine と組み合わせて監視する
export type Connectivity = 'online' | 'offline';

export function startMonitor(onChange: (state: Connectivity) => void, intervalMs = 60_000) {
  let state: Connectivity | null = null;
  let failures = 0;
  let running = false;
  let timer: number | undefined;

  const set = (next: Connectivity) => {
    if (next === state) return;
    state = next;
    onChange(next);
  };

  const probe = async () => {
    if (running) return;
    running = true;
    window.clearTimeout(timer);
    try {
      if (!navigator.onLine) {
        set('offline'); // false は信じてよい
      } else if (await checkReachable()) {
        failures = 0;
        set('online');
      } else if (++failures >= 2 || state === null) {
        set('offline'); // 2 回続けて失敗したら切り替える(起動直後は 1 回で決める)
      }
    } finally {
      running = false;
      // オフライン中や失敗の直後は 10 秒後、オンライン中は intervalMs 後に確かめる
      const wait = state === 'online' && failures === 0 ? intervalMs : 10_000;
      timer = window.setTimeout(() => void probe(), wait);
    }
  };

  window.addEventListener('offline', () => set('offline')); // 「切れた」はすぐ反映する
  window.addEventListener('online', () => void probe()); // 「戻った」は確かめてから
  void probe();
}

startMonitor((s) => {
  document.body.classList.toggle('is-offline', s === 'offline');
  console.log('connectivity:', s);
});

2. バックエンドから実装する (Rust)

ウィンドウが複数ある、ウィンドウを閉じてもトレイで動き続ける、といったアプリでは、確認を Rust にまとめ、結果をイベントで全ウィンドウに配ると重複しません。

use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use tauri::{AppHandle, Emitter, Manager, State};

// 自分のサーバーに用意した「204 を返すだけの URL」にするのが確実
const CHECK_URL: &str = "https://www.gstatic.com/generate_204";

struct Connectivity {
    online: AtomicBool,
    client: reqwest::Client,
}

impl Connectivity {
    fn new() -> Self {
        let client = reqwest::Client::builder()
            .timeout(Duration::from_secs(5)) // 応答まで最長 5 秒
            .redirect(reqwest::redirect::Policy::none()) // ログイン画面へのリダイレクトを追わない
            .build()
            .expect("failed to build HTTP client");
        Self { online: AtomicBool::new(true), client }
    }

    // 1 回の失敗ではオフラインにしない。2 秒おいてもう一度試す
    async fn reachable(&self) -> bool {
        for attempt in 0..2 {
            if attempt > 0 {
                tokio::time::sleep(Duration::from_secs(2)).await;
            }
            if let Ok(res) = self.client.get(CHECK_URL).send().await {
                if res.status() == reqwest::StatusCode::NO_CONTENT {
                    return true;
                }
            }
        }
        false
    }
}

/// 確かめて、状態が変わったときだけ全ウィンドウに通知する
async fn probe(app: &AppHandle) -> bool {
    let state = app.state::<Connectivity>();
    let online = state.reachable().await;
    if state.online.swap(online, Ordering::Relaxed) != online {
        let _ = app.emit("connectivity-changed", online);
    }
    online
}

#[tauri::command]
async fn check_connectivity(app: AppHandle) -> bool {
    probe(&app).await
}

#[tauri::command]
fn is_online(state: State<'_, Connectivity>) -> bool {
    state.online.load(Ordering::Relaxed)
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .plugin(tauri_plugin_http::init()) // 1 章の fetch を使う場合
        .manage(Connectivity::new())
        .setup(|app| {
            let handle = app.handle().clone();
            tauri::async_runtime::spawn(async move {
                loop {
                    let online = probe(&handle).await;
                    // オフライン中は 10 秒、オンライン中は 60 秒ごとに確かめる
                    tokio::time::sleep(Duration::from_secs(if online { 60 } else { 10 })).await;
                }
            });
            Ok(())
        })
        .invoke_handler(tauri::generate_handler![check_connectivity, is_online])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

起動直後の emit は、ページが listen を登録する前に送られて取りこぼすことがあります。現在の値を返す is_online を用意し、listen の後に一度呼んで合わせます。listen の権限は core:default に含まれています。

import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';

export async function watchFromRust(render: (online: boolean) => void) {
  const unlisten = await listen<boolean>('connectivity-changed', (e) => render(e.payload));
  render(await invoke<boolean>('is_online')); // listen より前に送られた通知の分を取り戻す
  window.addEventListener('offline', () => render(false)); // 「切れた」は待たずに反映
  window.addEventListener('online', async () => {
    render(await invoke<boolean>('check_connectivity')); // 「戻った」はすぐ確かめる
  });
  return unlisten;
}

動作確認

npm run tauri dev で起動すると、コンソールに connectivity: online と出ます。Wi-Fi を切ると、offline イベントが来ればすぐに、来なくても次の確認で connectivity: offline になり、戻すと確認が済んでから online に戻ります。「繋がっているのに届かない」状態は、Rust 版の CHECK_URL を応答しない宛先(例: http://10.255.255.1/)に変えると試せます。navigator.onLine は true のままですが、確認はタイムアウトしてオフラインと判定されます。

よくあるエラーと対処法

  • いつもオフラインと判定される: 標準の fetch で CORS を許可していない URL を確かめています。http プラグインの fetch か Rust で確かめます。
  • 「url not allowed on the configured scope: https://www.gstatic.com/generate_204」: http プラグインの許可リストに確認先がありません。
  • キャプティブポータルの Wi-Fi でもオンラインになる: res.ok で判定しています。ログイン画面は 200 を返すので、204 と一致するかで判定します。
  • 確認に 20 秒以上かかる: タイムアウトを付けていません。TCP で 1.1.1.1:53 などに繋いで確かめる方法も同じで、OS の既定では長く待たされます。また TCP が繋がってもキャプティブポータルやプロキシは見抜けないので、HTTP で確かめる方が確実です。
  • listen しても最初の状態が届かない: 登録前の emit を取りこぼしています。is_online で現在の値を取ります。

OS ごとの違いと注意点

  • navigator.onLine の判定: Webview のエンジンと OS のネットワーク状態から決まり、細かい判定は OS によって違います。どの OS でも「true なら届く」とは考えず、確認と組み合わせます。
  • 確認の頻度: 数秒ごとに確かめると、ノート PC の電池や従量課金の回線を消費します。オンライン中は 1 分程度にし、実際の通信が失敗したときや online イベントのときに追加で確かめます。
  • 確認先の選び方: 社内ネットワーク専用のアプリなら、社内のサーバーを確認先にします。インターネットに出られなくても、アプリとしては使えるためです。

関連レシピ

参考リンク(公式ドキュメント)

Web Ninja

この記事を書いた人

Web Ninja ウェブエンジニア (Web Engineer)

会社員ネットワークエンジニアから独立してかれこれ 25 年以上 Web エンジニアとして活動中。普段は JavaScript と Node.js を自在に操り、時には C++ や Perl といった古流の技も嗜みます。近年は Tauri × Rust という新たな武器を手に、デスクトップアプリ開発の最前線を駆け抜けています。「作りたい」を「作れる」に変えるための、実践的な「技」をお届けします。

お問い合わせ: tauri.ninja@gmail.com

内容の誤り・動かないコードを報告する