時計の表示やログのタイムスタンプ、「3 分後に通知する」ような予約処理には現在時刻が要ります。フロントエンドは JS の Date、Rust は chrono クレート(標準ライブラリだけなら std::time::SystemTime)で取れます。どちらも同じ OS の時計を読むので値そのものはずれませんが、Tauri アプリでは両者の間で時刻を受け渡すところでつまずきます。invoke は JSON でやり取りするため Date 型がそのままは届かないこと、タイムゾーンの扱い、精度の違いを中心に説明します。
前提条件
プラグインや権限は不要です(アプリ自身のコマンドは capability の設定なしで呼べます)。Rust で chrono を使うなら src-tauri/Cargo.toml に追加します。既定の機能で Utc::now() と Local::now() が使えます。
[dependencies]
chrono = "0.4"
features = ["serde"] を付けると、DateTime<Utc> を構造体のフィールドやコマンドの引数に直接書けるようになり、RFC 3339 形式の文字列として送受信されます。このレシピでは付けずに、文字列と数値で受け渡す方法を示します。
| やりたいこと | 使うもの |
|---|---|
| 画面に日時を表示する | JS の Date と Intl.DateTimeFormat |
| Rust の処理(ログ・ファイル名)に時刻を付ける | chrono の Utc::now() / Local::now() |
| 経過時間を測る | JS の performance.now() / Rust の std::time::Instant |
| JS と Rust で時刻を受け渡す | エポックミリ秒(数値)か RFC 3339 の文字列 |
1. フロントエンドから実装する (TypeScript)
画面の時計は JS だけで作れます。Intl.DateTimeFormat は timeZone を省略すると OS のタイムゾーンで表示し、resolvedOptions().timeZone で「Asia/Tokyo」のような名前も取れます。setInterval(…, 1000) は少しずつ遅れて秒の表示が飛ぶことがあるので、次の秒の変わり目に合わせて待ち時間を決めます。
const fmt = new Intl.DateTimeFormat('ja-JP', {
dateStyle: 'medium',
timeStyle: 'medium', // 例: 2026/09/12 12:04:05
});
export function startClock(el: HTMLElement) {
const tick = () => {
el.textContent = fmt.format(new Date());
window.setTimeout(tick, 1000 - (Date.now() % 1000)); // 次の秒の変わり目まで待つ
};
tick();
}
// OS のタイムゾーン名と、UTC からのずれ(分)
const zone = Intl.DateTimeFormat().resolvedOptions().timeZone; // "Asia/Tokyo"
const offsetMin = -new Date().getTimezoneOffset(); // 日本なら 540
console.log(zone, offsetMin);
getTimezoneOffset() は「UTC − ローカル」を分で返すので、日本では -540 と符号が逆になります。後述の Rust の local_minus_utc() は「ローカル − UTC」を秒で返す(32400)ので、両方を扱うときは単位と符号をそろえます。
2. バックエンドから実装する (Rust)
Rust で取った時刻を JS に渡す
invoke の戻り値は JSON なので、Rust で何を返しても JS 側で Date にはなりません。JS に渡す時刻は、new Date(n) にそのまま渡せるエポックミリ秒(i64)か、new Date(s) で読める RFC 3339 の文字列にします。1 回の now() から全部の値を作れば、同じ瞬間を指していることが保証されます。
use chrono::{DateTime, Local, SecondsFormat, Utc};
use serde::Serialize;
#[derive(Serialize)]
#[serde(rename_all = "camelCase")] // 戻り値のフィールド名は自動では変換されない
struct NowInfo {
epoch_ms: i64, // 1789182245123(UTC 基準。タイムゾーンに左右されない)
utc: String, // "2026-09-12T03:04:05.123Z"
local: String, // "2026-09-12T12:04:05.123+09:00"
offset_seconds: i32, // 32400(ローカル − UTC)
}
#[tauri::command]
fn now_info() -> NowInfo {
let now = Utc::now();
let local = now.with_timezone(&Local);
NowInfo {
epoch_ms: now.timestamp_millis(),
utc: now.to_rfc3339_opts(SecondsFormat::Millis, true),
local: local.to_rfc3339_opts(SecondsFormat::Millis, false),
offset_seconds: local.offset().local_minus_utc(),
}
}
コマンドの引数名は JS 側のキャメルケースと自動で対応しますが、戻り値の構造体はフィールド名がそのまま出ます。rename_all が無いと JS では info.epoch_ms になり、info.epochMs は undefined です。
秒未満の桁数もそろえます。to_rfc3339() は最大 9 桁の小数秒を出しますが、JS の Date はミリ秒までしか持てません。SecondsFormat::Millis で 3 桁にすると、ECMAScript が定める日付文字列の形式どおりになります。数値で渡す場合も、ナノ秒(timestamp_nanos_opt()、現在は約 1.8 × 10^18)は JS の Number が正確に表せる整数の範囲(約 9 × 10^15)を超えて下の桁が丸められるので、ミリ秒にします。
JS から時刻を受け取る
逆向きも同じで、Date を invoke の引数に入れると、JSON に変換される時点で toISOString() と同じ UTC の文字列("2026-09-12T03:04:05.123Z")になります。Rust では String で受けて DateTime::parse_from_rfc3339() で読みます。i64 で受けたいなら、JS 側で getTime() の値を渡します。
use chrono::{DateTime, Local, SecondsFormat, Utc};
/// 予定時刻までの残り秒数を返す。at には JS の Date をそのまま渡せる
#[tauri::command]
fn seconds_until(at: String) -> Result<i64, String> {
let at: DateTime<Utc> = DateTime::parse_from_rfc3339(&at)
.map_err(|e| format!("invalid datetime '{at}': {e}"))?
.with_timezone(&Utc);
Ok(at.signed_duration_since(Utc::now()).num_seconds())
}
/// エポックミリ秒で受け取り、ローカル時刻の文字列にする
#[tauri::command]
fn local_text_from_ms(ms: i64) -> Result<String, String> {
let at = DateTime::<Utc>::from_timestamp_millis(ms).ok_or("out of range")?;
Ok(at.with_timezone(&Local).format("%Y-%m-%d %H:%M:%S").to_string())
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![now_info, seconds_until, local_text_from_ms])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
import { invoke } from '@tauri-apps/api/core';
type NowInfo = { epochMs: number; utc: string; local: string; offsetSeconds: number };
const info = await invoke<NowInfo>('now_info');
const rustNow = new Date(info.epochMs); // 数値から Date に戻す
console.log(info.local, rustNow.getTime() - Date.now()); // 差は IPC の往復分だけ
// Date はそのまま渡せる(UTC の ISO 文字列になって届く)
const at = new Date(Date.now() + 3 * 60 * 1000);
console.log(await invoke<number>('seconds_until', { at }));
console.log(await invoke<string>('local_text_from_ms', { ms: at.getTime() }));
動作確認
npm run tauri dev で起動し、上の 2 つの TypeScript を実行すると、日本時間の環境では次のように出ます。epochMs から作った Date と Date.now() の差は数ミリ秒で、seconds_until は端数が切り捨てられて 179 になります。
Asia/Tokyo 540
2026-09-12T12:04:05.123+09:00 -2
179
2026-09-12 12:07:05
OS のタイムゾーンを変えてアプリを起動し直すと、JS の表示と Rust の local がそろって変わり、epochMs と utc は変わりません。
よくあるエラーと対処法
- 「invalid args
msfor commandlocal_text_from_ms:」で始まるエラー:i64の引数にDateをそのまま渡しています。Dateは文字列になって届くので、getTime()で数値にします。 info.epochMsがundefinedで、new Date()が Invalid Date になる: 戻り値の構造体に#[serde(rename_all = "camelCase")]がありません。invoke<{ at: Date }>と書いたのに「getTime is not a function」という趣旨の TypeError: 型引数は実行時の変換をしません。届くのは文字列なのでnew Date(at)で変換します。DateTime<Utc>を構造体に入れると Serialize が実装されていないという趣旨のコンパイルエラー: chrono のserde機能が無効です。features = ["serde"]を付けるか、このレシピのように文字列や数値に変えてから入れます。- 時刻が 9 時間ずれる:
Utc::now()をformat()した文字列をローカル時刻として表示しているか、オフセットの無い"2026-09-12T03:04:05"を JS に渡しています。JS はオフセットの無い日時をローカル時刻として解釈します。受け渡しにはZかオフセット付きの形式を使います。
注意点
- 時計は戻ることがある:
Date.now()とUtc::now()は OS の時計なので、ユーザーの設定変更や時刻合わせで進んだり戻ったりします。処理時間やタイムアウトには、戻らないperformance.now()とstd::time::Instantを使います。Instantはエポックとの対応を持たず JS に渡せないので、経過時間は Rust で計算してミリ秒で返します。 - オフセットはタイムゾーンではない:
+09:00はその瞬間のずれでしかありません。夏時間のある地域では、固定オフセットのまま日付をまたぐ計算をすると切り替えの前後で 1 時間ずれます。計算は UTC で行い、表示の変換はIntl.DateTimeFormatに任せるのが安全です。 - 保存は UTC で: ファイルや DB に書く時刻はエポックミリ秒か
Z付きの文字列にしておくと、タイムゾーンを変えた後や別の PC で読んでも意味が変わりません。
