diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..cd414e4 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,53 @@ +name: Documentation + +on: + push: + branches: + - main + paths: + - 'docs/**' + - 'zensical.toml' + - '.github/workflows/docs.yml' + pull_request: + paths: + - 'docs/**' + - 'zensical.toml' + - '.github/workflows/docs.yml' + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/configure-pages@v6 + - uses: actions/checkout@v7 + - uses: actions/setup-python@v7 + with: + python-version: '3.x' + # 0.0.x のため固定。更新時はローカルで zensical build を通してから上げる + - run: pip install zensical==0.0.74 + - run: zensical build --clean --strict + - uses: actions/upload-pages-artifact@v5 + with: + path: site + + # 公開は main への push のみ(PRではビルド確認だけ) + deploy: + if: github.event_name != 'pull_request' + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - uses: actions/deploy-pages@v5 + id: deployment diff --git a/.gitignore b/.gitignore index 4cd51f9..85f29df 100644 --- a/.gitignore +++ b/.gitignore @@ -23,5 +23,7 @@ dist-ssr *.sln *.sw? .claude/worktrees/ -docs/ CLAUDE.md + +# Zensical のビルド出力 +site/ diff --git a/docs/google-sync.md b/docs/google-sync.md new file mode 100644 index 0000000..e07195f --- /dev/null +++ b/docs/google-sync.md @@ -0,0 +1,113 @@ +# Google カレンダー / Google ToDo 連携セットアップ + +FastTask のタスクを **Google へ書き出す(push)だけ** の一方向連携です。 +Google 側で作ったり編集したりした予定・ToDo が FastTask に取り込まれることはありません。 + +アプリにはクライアントシークレットを埋め込んでいないため、**各自の Google Cloud プロジェクトで +OAuth クライアントIDを発行**して設定画面に入力する必要があります。以下はその手順です。 + +--- + +## 1. Google Cloud プロジェクトを作る + +1. を開く +2. 画面上部のプロジェクト選択 → 「新しいプロジェクト」 +3. プロジェクト名は何でもよい(例: `FastTask`)→ 作成 + +## 2. API を有効化する + +左メニュー「API とサービス」→「ライブラリ」から、次の2つを検索して **有効にする**。 + +- **Google Calendar API** +- **Google Tasks API** + +## 3. OAuth 同意画面を設定する + +左メニュー「API とサービス」→「OAuth 同意画面」。 + +1. User Type は **外部(External)** を選択(Google Workspace 組織内だけで使うなら「内部」でも可) +2. アプリ名(例: `FastTask`)、ユーザーサポートメール、デベロッパー連絡先メールを入力 +3. **スコープ**: ここでは何も追加しなくて構いません(アプリ側が要求します)。 + FastTask が要求するスコープは最小限の3つだけです。 + - `.../auth/calendar.events` … カレンダーの予定の作成・更新 + - `.../auth/tasks` … ToDo の作成・更新 + - `.../auth/userinfo.email` … 設定画面に「接続中のアカウント」を表示するためだけに使用 +4. **テストユーザー**: 「公開ステータス」が *テスト* のままで問題ありません。 + 「テストユーザー」に **自分の Google アカウントのメールアドレスを必ず追加**してください。 + ここに登録していないアカウントでは認証が `access_denied` で失敗します。 + +> テストモードの refresh token は **7日で失効**します。失効したら設定画面で「接続を解除」→ +> 「Googleに接続」をやり直してください。恒久的に使いたい場合はアプリを「本番」に公開します +> (自分だけが使う機密スコープなら審査なしで公開できますが、警告画面は出ます)。 + +## 4. OAuth クライアントID を発行する + +左メニュー「API とサービス」→「認証情報」→「認証情報を作成」→「OAuth クライアント ID」。 + +- **アプリケーションの種類: 「デスクトップ アプリ」を選んでください。** +- 名前は任意(例: `FastTask desktop`) + +> **リダイレクトURIの入力欄は出てきません。これで正しいです。** +> 「デスクトップ アプリ」タイプは `http://127.0.0.1:<任意のポート>` へのリダイレクトが +> 自動的に許可される仕様で、FastTask は起動のたびに OS から空きポートを借りて使います +> ([Google: Loopback IP address flow](https://developers.google.com/identity/protocols/oauth2/native-app))。 +> もし種類を「ウェブ アプリケーション」で作ってしまうと、ポートが毎回変わるため動きません。作り直してください。 + +発行されると **クライアントID** と **クライアントシークレット** が表示されます。両方控えます。 +(デスクトップアプリのシークレットは Google の仕様上「機密ではない」扱いですが、FastTask は +念のためローカルに `600` パーミッションで保存します。) + +## 5. FastTask に入力して接続する + +1. FastTask の 設定(⌘,)→ **Google連携** +2. クライアントID / クライアントシークレットを貼り付けて「保存」 +3. 「Googleに接続」を押す → 既定のブラウザが開く +4. アカウントを選び、権限を許可する + - 「このアプリは確認されていません」と出たら「詳細」→「(アプリ名)に移動」を押す(テストモードのため) +5. ブラウザに「連携が完了しました」と出たらタブを閉じる。設定画面に接続中のアカウントが表示されます。 + +## 6. 書き出し設定 + +| 項目 | 内容 | +| --- | --- | +| ToDoに書き出す | タスクを Google ToDo に作成。FastTask で完了にすると Google 側も完了になります | +| カレンダーに書き出す | **期日のあるタスクだけ**を、その日の終日イベントとして作成します | +| 自動書き出し | タスクの追加・更新時に自動で push(既定OFF)。失敗してもアプリの操作は止まりません | +| カレンダーID | 既定 `primary`(メインのカレンダー)。別カレンダーに入れるなら Google カレンダーの設定からIDをコピー | +| ToDoリスト | 既定 `@default`。「リスト取得」を押すとリスト名から選べます | +| 今すぐ同期 | 全タスクを手動で push | + +--- + +## 仕様・制限 + +- **一方向だけです。** Google 側の変更は FastTask に戻ってきません。 +- **FastTask でタスクを削除しても、Google 側は削除されません**(対応表から外れるだけ)。 + Google 側で手動で消してください。 +- 期日を外すと、そのタスクのカレンダー予定は削除されます。 +- 前回 push した内容(タイトル・状態・期日・URL)と変わっていないタスクは API を呼びません。 +- 自動書き出しは保存のたびに走りますが、前回の push が実行中なら今回はスキップします + (最新の状態は次の保存で反映されます)。 + +## 保存される場所 + +アプリデータフォルダ(macOS は `~/Library/Application Support/com.fasttask.desktop/`)。 + +| ファイル | 中身 | +| --- | --- | +| `google.json` | クライアントID・シークレット・リフレッシュトークン・各種設定(**0600**) | +| `google_map.json` | FastTask のタスクID ↔ Google 側ID の対応表 | + +タスク本体(`data.json`)に認証情報は混ざりません。 +「接続を解除」を押すと Google 側でトークンを失効させ、手元の認証情報と対応表を消します。 + +## うまくいかないとき + +| 症状 | 原因と対処 | +| --- | --- | +| `access_denied` | OAuth同意画面の「テストユーザー」に自分のアカウントが入っていない | +| `refresh_token が返りませんでした` | 同じクライアントで過去に許可済み。設定で「接続を解除」してからやり直す | +| `invalid_client` | クライアントID/シークレットの貼り間違い、または種類が「デスクトップ アプリ」でない | +| 数日経つと同期エラー | テストモードの7日失効。再接続するか、アプリを本番公開する | +| `Tasks リストが見つかりません` | ToDoリストIDが違う。「リスト取得」で選び直す | +| 認証がタイムアウトしました | ブラウザでの許可を5分以内に完了する必要があります | diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..7b6c4c3 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,12 @@ +# FastTask Docs + +FastTask は macOS 向けの軽量タスク管理アプリです。ここにはアプリの設定や外部連携のセットアップ手順を置いています。 + +## ドキュメント + +- [Google連携セットアップ](google-sync.md) — FastTask のタスクを Google カレンダー / Google ToDo へ書き出すための OAuth クライアント設定手順 + +## リンク + +- [リポジトリ](https://github.com/riiimparm/FastTask) +- [リリース](https://github.com/riiimparm/FastTask/releases) diff --git a/mcp/README.md b/mcp/README.md new file mode 100644 index 0000000..26acaa3 --- /dev/null +++ b/mcp/README.md @@ -0,0 +1,70 @@ +# FastTask MCP サーバ + +FastTask が起動時に立てるローカル HTTP API (127.0.0.1 のみ) を叩く、依存ゼロの MCP stdio ブリッジ。 + +必要条件: Node 18+ / FastTask アプリが起動していること。 + +## 仕組み + +FastTask は起動時に `app_data_dir/api.json` へ実ポートと認証トークンを書き出す。 + +- macOS: `~/Library/Application Support/com.fasttask.desktop/api.json` +- Linux: `~/.local/share/com.fasttask.desktop/api.json` +- Windows: `%APPDATA%\com.fasttask.desktop\api.json` + +MCP サーバはこのファイルを毎リクエスト読み直すので、設定は不要。環境変数 +`FASTTASK_PORT` / `FASTTASK_TOKEN` / `FASTTASK_APP_DATA_DIR` で上書きできる。 + +## 登録 + +Claude Code: + +```sh +claude mcp add fasttask -- node /absolute/path/to/FastTask/mcp/server.js +``` + +Claude Desktop (`claude_desktop_config.json`) / 手動設定: + +```json +{ + "mcpServers": { + "fasttask": { + "command": "node", + "args": ["/absolute/path/to/FastTask/mcp/server.js"] + } + } +} +``` + +## tools + +| tool | 引数 | +| --- | --- | +| `list_tasks` | `status?` (`todo` \| `done`) | +| `create_task` | `title` (必須), `dueDate?`, `url?`, `tags?` | +| `update_task` | `id` (必須), `title?`, `status?`, `dueDate?`, `url?`, `tags?` | +| `delete_task` | `id` (必須) — 子タスクも一緒に削除 | + +## HTTP API を直接叩く + +全リクエストに `Authorization: Bearer ` が必須 (無いと 401)。 + +```sh +BASE=http://127.0.0.1:$(sed -n 's/.*"port": *\([0-9]*\).*/\1/p' ~/Library/Application\ Support/com.fasttask.desktop/api.json) +TOKEN=$(sed -n 's/.*"token": *"\([^"]*\)".*/\1/p' ~/Library/Application\ Support/com.fasttask.desktop/api.json) + +curl "$BASE/tasks?status=todo" -H "Authorization: Bearer $TOKEN" +curl -X POST "$BASE/tasks" -H "Authorization: Bearer $TOKEN" -d '{"title":"資料を書く","dueDate":"2026-11-10"}' +curl -X PATCH "$BASE/tasks/" -H "Authorization: Bearer $TOKEN" -d '{"status":"done"}' +curl -X DELETE "$BASE/tasks/" -H "Authorization: Bearer $TOKEN" +``` + +## 動作確認 + +FastTask を起動した状態で: + +```sh +sh mcp/smoke.sh +``` + +トークン無し → 401、作成 → 一覧に出る、を確認して後片付けする。 diff --git a/mcp/server.js b/mcp/server.js new file mode 100644 index 0000000..91e810c --- /dev/null +++ b/mcp/server.js @@ -0,0 +1,135 @@ +#!/usr/bin/env node +// FastTask MCP stdio bridge -> ローカルHTTP API。依存なし (Node 18+ の fetch を使用)。 +import { readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { createInterface } from "node:readline"; + +const APP_DATA_DIR = + process.env.FASTTASK_APP_DATA_DIR || + (process.platform === "darwin" + ? join(homedir(), "Library", "Application Support", "com.fasttask.desktop") + : process.platform === "win32" + ? join(process.env.APPDATA || "", "com.fasttask.desktop") + : join(homedir(), ".local", "share", "com.fasttask.desktop")); + +function config() { + let port = process.env.FASTTASK_PORT; + let token = process.env.FASTTASK_TOKEN; + if (!port || !token) { + const f = JSON.parse(readFileSync(join(APP_DATA_DIR, "api.json"), "utf8")); + port ||= f.port; + token ||= f.token; + } + return { base: `http://127.0.0.1:${port}`, token }; +} + +async function api(method, path, body) { + const { base, token } = config(); + const res = await fetch(base + path, { + method, + headers: { + Authorization: `Bearer ${token}`, + ...(body ? { "Content-Type": "application/json" } : {}), + }, + body: body ? JSON.stringify(body) : undefined, + }); + const text = await res.text(); + if (!res.ok) throw new Error(`${res.status}: ${text}`); + return text; +} + +const TOOLS = [ + { + name: "list_tasks", + description: "FastTask のタスク一覧を取得する", + inputSchema: { + type: "object", + properties: { status: { type: "string", enum: ["todo", "done"] } }, + }, + run: (a) => api("GET", `/tasks${a.status ? `?status=${encodeURIComponent(a.status)}` : ""}`), + }, + { + name: "create_task", + description: "FastTask にタスクを作成する", + inputSchema: { + type: "object", + properties: { + title: { type: "string" }, + dueDate: { type: "string", description: "ISO 日付 (YYYY-MM-DD)" }, + url: { type: "string" }, + tags: { type: "array", items: { type: "string" } }, + }, + required: ["title"], + }, + run: (a) => api("POST", "/tasks", a), + }, + { + name: "update_task", + description: "タスクを部分更新する (title / status / dueDate / url / tags)", + inputSchema: { + type: "object", + properties: { + id: { type: "string" }, + title: { type: "string" }, + status: { type: "string", enum: ["todo", "done"] }, + dueDate: { type: "string" }, + url: { type: "string" }, + tags: { type: "array", items: { type: "string" } }, + }, + required: ["id"], + }, + run: ({ id, ...patch }) => api("PATCH", `/tasks/${encodeURIComponent(id)}`, patch), + }, + { + name: "delete_task", + description: "タスクを削除する (子タスクも削除される)", + inputSchema: { + type: "object", + properties: { id: { type: "string" } }, + required: ["id"], + }, + run: ({ id }) => api("DELETE", `/tasks/${encodeURIComponent(id)}`), + }, +]; + +function send(msg) { + process.stdout.write(JSON.stringify({ jsonrpc: "2.0", ...msg }) + "\n"); +} + +async function handle(req) { + switch (req.method) { + case "initialize": + return { + protocolVersion: req.params?.protocolVersion || "2024-11-05", + capabilities: { tools: {} }, + serverInfo: { name: "fasttask", version: "0.1.0" }, + }; + case "tools/list": + return { tools: TOOLS.map(({ name, description, inputSchema }) => ({ name, description, inputSchema })) }; + case "tools/call": { + const tool = TOOLS.find((t) => t.name === req.params?.name); + if (!tool) throw new Error(`unknown tool: ${req.params?.name}`); + const text = await tool.run(req.params.arguments || {}); + return { content: [{ type: "text", text }] }; + } + default: + throw new Error(`unknown method: ${req.method}`); + } +} + +createInterface({ input: process.stdin }).on("line", async (line) => { + if (!line.trim()) return; + let req; + try { + req = JSON.parse(line); + } catch { + return; + } + try { + const result = await handle(req); + if (req.id !== undefined) send({ id: req.id, result }); + } catch (e) { + if (req.id !== undefined) send({ id: req.id, error: { code: -32000, message: String(e.message || e) } }); + } +}); diff --git a/mcp/smoke.sh b/mcp/smoke.sh new file mode 100755 index 0000000..53e6f12 --- /dev/null +++ b/mcp/smoke.sh @@ -0,0 +1,26 @@ +#!/bin/sh +# FastTask ローカルAPI の最小動作確認。FastTask を起動した状態で実行する。 +# sh mcp/smoke.sh +set -e +DIR="${FASTTASK_APP_DATA_DIR:-$HOME/Library/Application Support/com.fasttask.desktop}" +PORT=$(sed -n 's/.*"port": *\([0-9]*\).*/\1/p' "$DIR/api.json") +TOKEN=$(sed -n 's/.*"token": *"\([^"]*\)".*/\1/p' "$DIR/api.json") +BASE="http://127.0.0.1:$PORT" + +code=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/tasks") +[ "$code" = "401" ] || { echo "FAIL: トークン無しが $code (401 期待)"; exit 1; } +echo "ok: トークン無し -> 401" + +TITLE="smoke-$(date +%s)" +ID=$(curl -s -X POST "$BASE/tasks" -H "Authorization: Bearer $TOKEN" \ + -H 'Content-Type: application/json' -d "{\"title\":\"$TITLE\"}" \ + | sed -n 's/.*"id":"\([^"]*\)".*/\1/p') +[ -n "$ID" ] || { echo "FAIL: 作成できなかった"; exit 1; } +echo "ok: 作成 id=$ID" + +curl -s "$BASE/tasks?status=todo" -H "Authorization: Bearer $TOKEN" | grep -q "$TITLE" \ + || { echo "FAIL: 一覧に出ない"; exit 1; } +echo "ok: 一覧に出る" + +curl -s -X DELETE "$BASE/tasks/$ID" -H "Authorization: Bearer $TOKEN" >/dev/null +echo "ok: 後片付け完了 — PASS" diff --git a/package.json b/package.json index 2186004..eb1bb4c 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,6 @@ "@tauri-apps/plugin-opener": "^2", "date-fns": "^4.4.0", "react": "^19.1.0", - "react-day-picker": "^10.0.1", "react-dom": "^19.1.0", "uuid": "^14.0.0", "zustand": "^5.0.14" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c8db176..f749d0d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,9 +38,6 @@ importers: react: specifier: ^19.1.0 version: 19.2.6 - react-day-picker: - specifier: ^10.0.1 - version: 10.0.1(@types/react@19.2.15)(react@19.2.6) react-dom: specifier: ^19.1.0 version: 19.2.6(react@19.2.6) @@ -171,9 +168,6 @@ packages: resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} - '@date-fns/tz@1.5.0': - resolution: {integrity: sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==} - '@dnd-kit/accessibility@3.1.1': resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} peerDependencies: @@ -929,16 +923,6 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} - react-day-picker@10.0.1: - resolution: {integrity: sha512-eNh6BlwcYInWaJtRv18mXQ06Ys/H6rdTZAnTaSdOYJuTpwP1JMCHNd1FDRadA+gbeinq+psdULN5Xnowy9mV8w==} - engines: {node: '>=18'} - peerDependencies: - '@types/react': '>=16.8.0' - react: '>=16.8.0' - peerDependenciesMeta: - '@types/react': - optional: true - react-dom@19.2.6: resolution: {integrity: sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==} peerDependencies: @@ -1217,8 +1201,6 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 - '@date-fns/tz@1.5.0': {} - '@dnd-kit/accessibility@3.1.1(react@19.2.6)': dependencies: react: 19.2.6 @@ -1797,14 +1779,6 @@ snapshots: queue-microtask@1.2.3: {} - react-day-picker@10.0.1(@types/react@19.2.15)(react@19.2.6): - dependencies: - '@date-fns/tz': 1.5.0 - date-fns: 4.4.0 - react: 19.2.6 - optionalDependencies: - '@types/react': 19.2.15 - react-dom@19.2.6(react@19.2.6): dependencies: react: 19.2.6 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index b5d38f4..34e156f 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -6,8 +6,12 @@ version = 4 name = "FastTask" version = "1.2.1" dependencies = [ + "base64 0.22.1", + "chrono", + "reqwest", "serde", "serde_json", + "sha2", "tauri", "tauri-build", "tauri-plugin-clipboard-manager", @@ -15,6 +19,9 @@ dependencies = [ "tauri-plugin-global-shortcut", "tauri-plugin-notification", "tauri-plugin-opener", + "tiny_http", + "url", + "uuid", ] [[package]] @@ -83,6 +90,12 @@ dependencies = [ "x11rb", ] +[[package]] +name = "ascii" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" + [[package]] name = "async-broadcast" version = "0.7.2" @@ -260,6 +273,29 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "aws-lc-rs" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "048006790404103c488e6c5616b3e7e702a71292aadda08e8f7698a9e0d2eb53" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.47.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bfbe32ac36b3c428696ce315464181bc4029d689fd58e3f2a4e58c252242f52" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + [[package]] name = "base64" version = "0.21.7" @@ -470,6 +506,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -512,6 +550,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + [[package]] name = "chrono" version = "0.4.44" @@ -519,11 +568,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ "iana-time-zone", + "js-sys", "num-traits", "serde", + "wasm-bindgen", "windows-link 0.2.1", ] +[[package]] +name = "chunked_transfer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901" + [[package]] name = "clipboard-win" version = "5.4.1" @@ -533,6 +590,15 @@ dependencies = [ "error-code", ] +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + [[package]] name = "combine" version = "4.6.7" @@ -562,6 +628,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -585,7 +661,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" dependencies = [ "bitflags 2.11.1", - "core-foundation", + "core-foundation 0.10.1", "core-graphics-types", "foreign-types", "libc", @@ -598,7 +674,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ "bitflags 2.11.1", - "core-foundation", + "core-foundation 0.10.1", "libc", ] @@ -611,6 +687,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -794,7 +879,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -935,6 +1020,15 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "endi" version = "1.1.1" @@ -986,7 +1080,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1123,6 +1217,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "futures-channel" version = "0.3.32" @@ -1333,8 +1433,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -1356,10 +1458,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", "wasip2", "wasip3", + "wasm-bindgen", ] [[package]] @@ -1633,6 +1738,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hyper" version = "1.10.1" @@ -1653,6 +1764,22 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.20" @@ -1671,9 +1798,11 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2", + "system-configuration", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -1688,7 +1817,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.56.0", + "windows-core 0.61.2", ] [[package]] @@ -1941,6 +2070,36 @@ dependencies = [ "windows-sys 0.45.0", ] +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link 0.2.1", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.117", +] + [[package]] name = "jni-sys" version = "0.3.1" @@ -1969,6 +2128,16 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + [[package]] name = "js-sys" version = "0.3.99" @@ -2105,6 +2274,12 @@ version = "0.4.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" +[[package]] +name = "lru-slab" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" + [[package]] name = "mac-notification-sys" version = "0.6.15" @@ -2200,7 +2375,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.18", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -2299,7 +2474,7 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" dependencies = [ - "proc-macro-crate 1.3.1", + "proc-macro-crate 3.5.0", "proc-macro2", "quote", "syn 2.0.117", @@ -2520,6 +2695,12 @@ dependencies = [ "pathdiff", ] +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "option-ext" version = "0.2.0" @@ -2543,7 +2724,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.45.0", + "windows-sys 0.61.2", ] [[package]] @@ -2884,6 +3065,63 @@ dependencies = [ "memchr", ] +[[package]] +name = "quinn" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.4.2", + "lru-slab", + "rand 0.10.3", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.52.0", +] + [[package]] name = "quote" version = "1.0.45" @@ -2926,6 +3164,17 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.3.1" @@ -2964,6 +3213,21 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -3047,21 +3311,30 @@ checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" dependencies = [ "base64 0.22.1", "bytes", + "encoding_rs", "futures-core", "futures-util", "http", "http-body", "http-body-util", "hyper", + "hyper-rustls", "hyper-util", "js-sys", "log", + "mime", "percent-encoding", "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", "serde", "serde_json", + "serde_urlencoded", "sync_wrapper", "tokio", + "tokio-rustls", "tokio-util", "tower", "tower-http", @@ -3073,6 +3346,20 @@ dependencies = [ "web-sys", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rustc-hash" version = "2.1.2" @@ -3098,7 +3385,82 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.59.0", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +dependencies = [ + "aws-lc-rs", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni 0.22.4", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" + +[[package]] +name = "rustls-webpki" +version = "0.103.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", ] [[package]] @@ -3107,6 +3469,12 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "same-file" version = "1.0.6" @@ -3116,6 +3484,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "schemars" version = "0.8.22" @@ -3173,6 +3550,29 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.11.1", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "selectors" version = "0.36.1" @@ -3297,6 +3697,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "serde_with" version = "3.21.0" @@ -3367,7 +3779,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -3378,7 +3790,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -3404,6 +3816,22 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "siphasher" version = "1.0.3" @@ -3429,7 +3857,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3522,6 +3950,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "swift-rs" version = "1.0.7" @@ -3574,6 +4008,27 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.11.1", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "system-deps" version = "6.2.2" @@ -3595,7 +4050,7 @@ checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" dependencies = [ "bitflags 2.11.1", "block2", - "core-foundation", + "core-foundation 0.10.1", "core-graphics", "crossbeam-channel", "dbus", @@ -3605,7 +4060,7 @@ dependencies = [ "gdkwayland-sys", "gdkx11-sys", "gtk", - "jni", + "jni 0.21.1", "libc", "log", "ndk", @@ -3661,7 +4116,7 @@ dependencies = [ "gtk", "heck 0.5.0", "http", - "jni", + "jni 0.21.1", "libc", "log", "mime", @@ -3878,7 +4333,7 @@ dependencies = [ "dpi", "gtk", "http", - "jni", + "jni 0.21.1", "objc2", "objc2-ui-kit", "objc2-web-kit", @@ -3901,7 +4356,7 @@ checksum = "b83849ee63ecb27a8e8d0fe51915ca215076914aca43f96db1179f0f415f6cd9" dependencies = [ "gtk", "http", - "jni", + "jni 0.21.1", "log", "objc2", "objc2-app-kit", @@ -3986,10 +4441,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4087,6 +4542,18 @@ dependencies = [ "time-core", ] +[[package]] +name = "tiny_http" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82" +dependencies = [ + "ascii", + "chunked_transfer", + "httpdate", + "log", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -4126,6 +4593,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -4354,7 +4831,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.18", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4394,7 +4871,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4456,6 +4933,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.8" @@ -4762,6 +5245,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "web_atoms" version = "0.2.4" @@ -4818,6 +5311,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webview2-com" version = "0.38.2" @@ -4882,7 +5384,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4960,7 +5462,7 @@ dependencies = [ "windows-interface 0.59.3", "windows-link 0.1.3", "windows-result 0.3.4", - "windows-strings", + "windows-strings 0.4.2", ] [[package]] @@ -5040,6 +5542,17 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + [[package]] name = "windows-result" version = "0.1.2" @@ -5058,6 +5571,15 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows-strings" version = "0.4.2" @@ -5067,6 +5589,15 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows-sys" version = "0.45.0" @@ -5486,7 +6017,7 @@ dependencies = [ "gtk", "http", "javascriptcore-rs", - "jni", + "jni 0.21.1", "libc", "ndk", "objc2", @@ -5753,6 +6284,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + [[package]] name = "zerotrie" version = "0.2.4" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 24625dc..694f142 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -21,3 +21,10 @@ tauri-plugin-global-shortcut = "2" tauri-plugin-notification = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" +tiny_http = "0.12" +uuid = { version = "1", features = ["v4"] } +chrono = "0.4" +reqwest = { version = "0.13", default-features = false, features = ["json", "form", "rustls", "charset", "system-proxy"] } +url = "2" +sha2 = "0.10" +base64 = "0.22" diff --git a/src-tauri/src/api.rs b/src-tauri/src/api.rs new file mode 100644 index 0000000..45513b8 --- /dev/null +++ b/src-tauri/src/api.rs @@ -0,0 +1,331 @@ +// AIエージェント連携用のローカルHTTP API (127.0.0.1 のみ)。 +use std::fs; +use std::io::Read; +use std::path::PathBuf; +use std::sync::Mutex; + +use serde_json::{json, Value}; +use tauri::{Emitter, Manager}; +use tiny_http::{Header, Request, Response, Server}; + +const MAX_BODY: usize = 1 << 20; // 1MiB +const PORT_MIN: u16 = 48730; +const PORT_MAX: u16 = 48760; + +// ponytail: プロセス内 Mutex で API 側の読み書きだけを直列化している。 +// 既知の上限: アプリ本体 (save_data) も同じ data.json を書くため、 +// フロントの保存と API の書き込みが重なると後勝ちで片方が失われうる。 +// 改善案: save_data とこの API を同一の共有 Mutex + ファイルロック(flock)経由にし、 +// 書き込み前に mtime を比較して競合を検出する。 +static DATA_LOCK: Mutex<()> = Mutex::new(()); + +struct Ctx { + app: tauri::AppHandle, + dir: PathBuf, + token: String, +} + +pub fn start(app: tauri::AppHandle) { + let dir = match app.path().app_data_dir() { + Ok(d) => d, + Err(e) => { + eprintln!("api: app_data_dir error: {e}"); + return; + } + }; + if let Err(e) = fs::create_dir_all(&dir) { + eprintln!("api: mkdir error: {e}"); + return; + } + + let (server, port) = match (PORT_MIN..PORT_MAX).find_map(|p| { + Server::http(("127.0.0.1", p)) + .ok() + .map(|s| (s, p)) + }) { + Some(v) => v, + None => { + eprintln!("api: no free port in {PORT_MIN}..{PORT_MAX}"); + return; + } + }; + + let token = load_or_create_token(&dir); + let info = dir.join("api.json"); + if let Err(e) = fs::write( + &info, + serde_json::to_string_pretty(&json!({ "port": port, "token": token })).unwrap(), + ) { + eprintln!("api: write api.json error: {e}"); + return; + } + + let ctx = Ctx { app, dir, token }; + std::thread::spawn(move || { + for req in server.incoming_requests() { + if let Err(e) = handle(&ctx, req) { + eprintln!("api: {e}"); + } + } + }); +} + +fn load_or_create_token(dir: &PathBuf) -> String { + let path = dir.join("api.json"); + if let Ok(s) = fs::read_to_string(&path) { + if let Ok(v) = serde_json::from_str::(&s) { + if let Some(t) = v.get("token").and_then(Value::as_str) { + if !t.is_empty() { + return t.to_string(); + } + } + } + } + format!( + "{}{}", + uuid::Uuid::new_v4().simple(), + uuid::Uuid::new_v4().simple() + ) +} + +fn respond(req: Request, status: u16, body: Value) -> std::io::Result<()> { + let header = Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]).unwrap(); + req.respond( + Response::from_string(body.to_string()) + .with_status_code(status) + .with_header(header), + ) +} + +fn handle(ctx: &Ctx, mut req: Request) -> std::io::Result<()> { + let authed = req + .headers() + .iter() + .any(|h| { + h.field.equiv("Authorization") + && h.value.as_str().strip_prefix("Bearer ") == Some(ctx.token.as_str()) + }); + if !authed { + return respond(req, 401, json!({ "error": "unauthorized" })); + } + + let (path, query) = { + let url = req.url().to_string(); + match url.split_once('?') { + Some((p, q)) => (p.to_string(), q.to_string()), + None => (url, String::new()), + } + }; + let method = req.method().as_str().to_string(); + + // ボディ読み込み (サイズ上限つき) + let mut body = String::new(); + if matches!(method.as_str(), "POST" | "PATCH") { + let len = req.body_length().unwrap_or(0); + if len > MAX_BODY { + return respond(req, 413, json!({ "error": "body too large" })); + } + if let Err(e) = req.as_reader().take(MAX_BODY as u64 + 1).read_to_string(&mut body) { + return respond(req, 400, json!({ "error": format!("read error: {e}") })); + } + if body.len() > MAX_BODY { + return respond(req, 413, json!({ "error": "body too large" })); + } + } + + let segs: Vec<&str> = path.trim_matches('/').split('/').collect(); + let result = match (method.as_str(), segs.as_slice()) { + ("GET", ["tasks"]) => list_tasks(ctx, &query), + ("POST", ["tasks"]) => create_task(ctx, &body), + ("PATCH", ["tasks", id]) => update_task(ctx, id, &body), + ("DELETE", ["tasks", id]) => delete_task(ctx, id), + _ => Err((404, "not found".to_string())), + }; + + match result { + Ok(v) => respond(req, 200, v), + Err((code, msg)) => respond(req, code, json!({ "error": msg })), + } +} + +type ApiResult = Result; + +fn data_path(ctx: &Ctx) -> PathBuf { + ctx.dir.join("data.json") +} + +fn read_data(ctx: &Ctx) -> Result { + let path = data_path(ctx); + if !path.exists() { + return Ok(json!({ "tasks": [], "tags": [], "version": "1" })); + } + let s = fs::read_to_string(&path).map_err(|e| (500, format!("read error: {e}")))?; + serde_json::from_str(&s).map_err(|e| (500, format!("parse error: {e}"))) +} + +fn write_data(ctx: &Ctx, data: &Value) -> Result<(), (u16, String)> { + let tmp = ctx.dir.join("data.json.tmp"); + let s = serde_json::to_string_pretty(data).map_err(|e| (500, format!("serialize: {e}")))?; + fs::write(&tmp, s).map_err(|e| (500, format!("write error: {e}")))?; + fs::rename(&tmp, data_path(ctx)).map_err(|e| (500, format!("rename error: {e}")))?; + let _ = ctx.app.emit("external-data-changed", ()); + // HTTP API 経由の変更も自動pushの対象にする(設定OFFなら何もしない) + if let Some(tasks) = data.get("tasks").and_then(Value::as_array) { + crate::google::auto_push(ctx.app.clone(), tasks.clone()); + } + Ok(()) +} + +fn tasks_of(data: &Value) -> Vec { + data.get("tasks") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default() +} + +fn list_tasks(ctx: &Ctx, query: &str) -> ApiResult { + let _g = DATA_LOCK.lock().unwrap(); + let data = read_data(ctx)?; + let status = query.split('&').find_map(|kv| kv.strip_prefix("status=")); + let mut tasks = tasks_of(&data); + if let Some(s) = status { + if s != "todo" && s != "done" { + return Err((400, "status must be todo or done".into())); + } + tasks.retain(|t| t.get("status").and_then(Value::as_str) == Some(s)); + } + Ok(json!({ "tasks": tasks })) +} + +fn parse_body(body: &str) -> Result { + let v: Value = serde_json::from_str(body).map_err(|e| (400, format!("invalid json: {e}")))?; + if !v.is_object() { + return Err((400, "body must be a JSON object".into())); + } + Ok(v) +} + +fn create_task(ctx: &Ctx, body: &str) -> ApiResult { + let b = parse_body(body)?; + let title = b + .get("title") + .and_then(Value::as_str) + .map(str::trim) + .filter(|s| !s.is_empty()) + .ok_or((400, "title is required".to_string()))?; + if title.chars().count() > 1000 { + return Err((400, "title too long".into())); + } + let tags = match b.get("tags") { + None | Some(Value::Null) => vec![], + Some(Value::Array(a)) if a.iter().all(Value::is_string) => a.clone(), + Some(_) => return Err((400, "tags must be an array of strings".into())), + }; + + let _g = DATA_LOCK.lock().unwrap(); + let mut data = read_data(ctx)?; + let mut tasks = tasks_of(&data); + let next_order = tasks + .iter() + .filter(|t| t.get("status").and_then(Value::as_str) == Some("todo")) + .filter_map(|t| t.get("order").and_then(Value::as_i64)) + .max() + .map_or(0, |m| m + 1); + + let mut task = json!({ + "id": uuid::Uuid::new_v4().to_string(), + "title": title, + "status": "todo", + "tags": tags, + "createdAt": chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true), + "order": next_order, + }); + for key in ["dueDate", "url"] { + match b.get(key) { + None | Some(Value::Null) => {} + Some(Value::String(s)) => { + task[key] = json!(s); + } + Some(_) => return Err((400, format!("{key} must be a string"))), + } + } + + tasks.push(task.clone()); + data["tasks"] = Value::Array(tasks); + write_data(ctx, &data)?; + Ok(task) +} + +fn update_task(ctx: &Ctx, id: &str, body: &str) -> ApiResult { + let b = parse_body(body)?; + let _g = DATA_LOCK.lock().unwrap(); + let mut data = read_data(ctx)?; + let mut tasks = tasks_of(&data); + let task = tasks + .iter_mut() + .find(|t| t.get("id").and_then(Value::as_str) == Some(id)) + .ok_or((404, "task not found".to_string()))?; + + if let Some(v) = b.get("title") { + let s = v + .as_str() + .map(str::trim) + .filter(|s| !s.is_empty() && s.chars().count() <= 1000) + .ok_or((400, "title must be a non-empty string".to_string()))?; + task["title"] = json!(s); + } + if let Some(v) = b.get("status") { + let s = v.as_str().unwrap_or(""); + if s != "todo" && s != "done" { + return Err((400, "status must be todo or done".into())); + } + task["status"] = json!(s); + if s == "done" { + task["completedAt"] = + json!(chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true)); + } else { + task["completedAt"] = Value::Null; + } + } + for key in ["dueDate", "url"] { + match b.get(key) { + None => {} + Some(Value::Null) => { + task[key] = Value::Null; + } + Some(Value::String(s)) => { + task[key] = json!(s); + } + Some(_) => return Err((400, format!("{key} must be a string or null"))), + } + } + if let Some(v) = b.get("tags") { + match v { + Value::Array(a) if a.iter().all(Value::is_string) => task["tags"] = v.clone(), + _ => return Err((400, "tags must be an array of strings".into())), + } + } + + let updated = task.clone(); + data["tasks"] = Value::Array(tasks); + write_data(ctx, &data)?; + Ok(updated) +} + +fn delete_task(ctx: &Ctx, id: &str) -> ApiResult { + let _g = DATA_LOCK.lock().unwrap(); + let mut data = read_data(ctx)?; + let mut tasks = tasks_of(&data); + let before = tasks.len(); + // 子タスクも一緒に消す (parentId でぶら下がる孤児を残さない) + tasks.retain(|t| { + t.get("id").and_then(Value::as_str) != Some(id) + && t.get("parentId").and_then(Value::as_str) != Some(id) + }); + if tasks.len() == before { + return Err((404, "task not found".into())); + } + data["tasks"] = Value::Array(tasks); + write_data(ctx, &data)?; + Ok(json!({ "deleted": id })) +} diff --git a/src-tauri/src/google.rs b/src-tauri/src/google.rs new file mode 100644 index 0000000..d1cca00 --- /dev/null +++ b/src-tauri/src/google.rs @@ -0,0 +1,755 @@ +//! Google カレンダー / Google Tasks への一方向 push 連携。 +//! +//! - OAuth 2.0 デスクトップ向けループバックフロー + PKCE (S256) +//! - クライアントID/シークレットはユーザーがGCPで発行して設定画面から入力する。アプリには埋め込まない。 +//! - 認証情報は app_data_dir/google.json に 0600 で保存し、data.json とは混ぜない。 +//! - FastTask id ↔ Google 側 id の対応は app_data_dir/google_map.json に保持。 + +use std::collections::HashMap; +use std::fs; +use std::path::PathBuf; +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +use base64::Engine; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use tauri::Manager; +use tauri_plugin_opener::OpenerExt; + +const AUTH_ENDPOINT: &str = "https://accounts.google.com/o/oauth2/v2/auth"; +const TOKEN_ENDPOINT: &str = "https://oauth2.googleapis.com/token"; +const REVOKE_ENDPOINT: &str = "https://oauth2.googleapis.com/revoke"; +const USERINFO_ENDPOINT: &str = "https://www.googleapis.com/oauth2/v3/userinfo"; +const CALENDAR_API: &str = "https://www.googleapis.com/calendar/v3/calendars"; +const TASKS_API: &str = "https://tasks.googleapis.com/tasks/v1"; + +/// calendar.events と tasks が本体。userinfo.email は設定画面の「接続中アカウント」表示にだけ使う。 +const SCOPES: &str = "https://www.googleapis.com/auth/calendar.events https://www.googleapis.com/auth/tasks https://www.googleapis.com/auth/userinfo.email"; + +const AUTH_TIMEOUT: Duration = Duration::from_secs(300); + +// ---------------------------------------------------------------- config + +#[derive(Serialize, Deserialize, Clone)] +#[serde(default)] +pub struct GoogleConfig { + pub client_id: String, + pub client_secret: String, + pub refresh_token: String, + pub email: String, + pub calendar_id: String, + pub tasklist_id: String, + pub auto_push: bool, + /// カレンダーへの終日イベント作成を行うか + pub push_calendar: bool, + /// Google Tasks への作成を行うか + pub push_tasks: bool, +} + +impl Default for GoogleConfig { + fn default() -> Self { + Self { + client_id: String::new(), + client_secret: String::new(), + refresh_token: String::new(), + email: String::new(), + calendar_id: "primary".into(), + tasklist_id: "@default".into(), + auto_push: false, + push_calendar: true, + push_tasks: true, + } + } +} + +/// フロントに返す安全なビュー。secret / refresh_token は決して含めない。 +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PublicConfig { + connected: bool, + has_secret: bool, + client_id: String, + email: String, + calendar_id: String, + tasklist_id: String, + auto_push: bool, + push_calendar: bool, + push_tasks: bool, +} + +impl From<&GoogleConfig> for PublicConfig { + fn from(c: &GoogleConfig) -> Self { + Self { + connected: !c.refresh_token.is_empty(), + has_secret: !c.client_secret.is_empty(), + client_id: c.client_id.clone(), + email: c.email.clone(), + calendar_id: c.calendar_id.clone(), + tasklist_id: c.tasklist_id.clone(), + auto_push: c.auto_push, + push_calendar: c.push_calendar, + push_tasks: c.push_tasks, + } + } +} + +fn dir(app: &tauri::AppHandle) -> Result { + let d = app + .path() + .app_data_dir() + .map_err(|e| format!("app_data_dir error: {e}"))?; + fs::create_dir_all(&d).map_err(|e| format!("mkdir error: {e}"))?; + Ok(d) +} + +pub fn load_config(app: &tauri::AppHandle) -> Result { + let path = dir(app)?.join("google.json"); + if !path.exists() { + return Ok(GoogleConfig::default()); + } + let s = fs::read_to_string(&path).map_err(|e| format!("read error: {e}"))?; + serde_json::from_str(&s).map_err(|e| format!("parse error: {e}")) +} + +/// 認証情報を含むので 0600 で書く。tmp に書いてから権限を付けて rename。 +fn save_config(app: &tauri::AppHandle, cfg: &GoogleConfig) -> Result<(), String> { + let d = dir(app)?; + let tmp = d.join("google.json.tmp"); + let s = serde_json::to_string_pretty(cfg).map_err(|e| format!("serialize error: {e}"))?; + fs::write(&tmp, s).map_err(|e| format!("write error: {e}"))?; + restrict(&tmp)?; + fs::rename(&tmp, d.join("google.json")).map_err(|e| format!("rename error: {e}")) +} + +#[cfg(unix)] +fn restrict(path: &PathBuf) -> Result<(), String> { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, fs::Permissions::from_mode(0o600)) + .map_err(|e| format!("chmod error: {e}")) +} + +// ponytail: Windows では ACL を触らず読み取り専用属性も付けない(ユーザープロファイル配下の +// 既定ACLに委ねる)。厳密にやるなら windows-acl 等で所有者のみに絞る。 +#[cfg(not(unix))] +fn restrict(_path: &PathBuf) -> Result<(), String> { + Ok(()) +} + +// ---------------------------------------------------------------- id map + +type IdMap = HashMap; + +#[derive(Serialize, Deserialize, Default, Clone)] +#[serde(default)] +struct MapEntry { + event_id: String, + gtask_id: String, + /// 前回 push した内容のハッシュ。一致すればAPIを呼ばない。 + hash: String, +} + +fn load_map(app: &tauri::AppHandle) -> IdMap { + let Ok(d) = dir(app) else { return IdMap::new() }; + fs::read_to_string(d.join("google_map.json")) + .ok() + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default() +} + +fn save_map(app: &tauri::AppHandle, map: &IdMap) -> Result<(), String> { + let d = dir(app)?; + let tmp = d.join("google_map.json.tmp"); + let s = serde_json::to_string_pretty(map).map_err(|e| format!("serialize error: {e}"))?; + fs::write(&tmp, s).map_err(|e| format!("write error: {e}"))?; + fs::rename(&tmp, d.join("google_map.json")).map_err(|e| format!("rename error: {e}")) +} + +// ---------------------------------------------------------------- oauth + +fn b64url(bytes: &[u8]) -> String { + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes) +} + +fn random_token() -> String { + format!( + "{}{}", + uuid::Uuid::new_v4().simple(), + uuid::Uuid::new_v4().simple() + ) +} + +/// ループバック受け口を立て、ブラウザで同意画面を開き、code を受け取って token と交換する。 +pub async fn connect(app: tauri::AppHandle) -> Result { + let mut cfg = load_config(&app)?; + if cfg.client_id.trim().is_empty() { + return Err("client_id が未設定です".into()); + } + + let verifier = format!("{}{}", random_token(), random_token()); // 128文字、PKCE の許容文字のみ + let challenge = b64url(&Sha256::digest(verifier.as_bytes())); + let state = random_token(); + + // ワンショットのループバックサーバ。ポートはOS任せ(0)。 + let server = tiny_http::Server::http(("127.0.0.1", 0)) + .map_err(|e| format!("loopback bind error: {e}"))?; + let port = server + .server_addr() + .to_ip() + .ok_or("loopback addr error")? + .port(); + let redirect_uri = format!("http://127.0.0.1:{port}"); + + let mut url = url::Url::parse(AUTH_ENDPOINT).map_err(|e| e.to_string())?; + url.query_pairs_mut() + .append_pair("client_id", &cfg.client_id) + .append_pair("redirect_uri", &redirect_uri) + .append_pair("response_type", "code") + .append_pair("scope", SCOPES) + .append_pair("code_challenge", &challenge) + .append_pair("code_challenge_method", "S256") + .append_pair("state", &state) + .append_pair("access_type", "offline") + .append_pair("prompt", "consent"); + + app.opener() + .open_url(url.as_str(), None::<&str>) + .map_err(|e| format!("ブラウザを開けませんでした: {e}"))?; + + // 受信はブロッキングなので blocking スレッドへ逃がす + let expected_state = state.clone(); + let code = tauri::async_runtime::spawn_blocking(move || wait_for_code(server, &expected_state)) + .await + .map_err(|e| format!("join error: {e}"))??; + + let token: Value = http() + .post(TOKEN_ENDPOINT) + .form(&[ + ("client_id", cfg.client_id.as_str()), + ("client_secret", cfg.client_secret.as_str()), + ("code", code.as_str()), + ("code_verifier", verifier.as_str()), + ("grant_type", "authorization_code"), + ("redirect_uri", redirect_uri.as_str()), + ]) + .send() + .await + .map_err(|_| "トークン取得に失敗しました(ネットワーク)".to_string())? + .json() + .await + .map_err(|_| "トークン応答の解析に失敗しました".to_string())?; + + if let Some(err) = token.get("error").and_then(Value::as_str) { + // ponytail: error_description は client_id 等を含みうるのでそのまま出さない + return Err(format!("トークン取得エラー: {err}")); + } + let refresh = token + .get("refresh_token") + .and_then(Value::as_str) + .ok_or("refresh_token が返りませんでした(GCPの同意画面設定を確認してください)")?; + let access = token + .get("access_token") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + + cfg.refresh_token = refresh.to_string(); + cfg.email = fetch_email(&access).await.unwrap_or_default(); + save_config(&app, &cfg)?; + cache_access(&access, token.get("expires_in").and_then(Value::as_u64).unwrap_or(3000)); + Ok((&cfg).into()) +} + +fn wait_for_code(server: tiny_http::Server, expected_state: &str) -> Result { + let deadline = Instant::now() + AUTH_TIMEOUT; + loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Err("認証がタイムアウトしました".into()); + } + let Some(req) = server + .recv_timeout(remaining) + .map_err(|e| format!("loopback recv error: {e}"))? + else { + continue; + }; + // favicon 等の無関係なリクエストは無視して待ち続ける + let full = format!("http://127.0.0.1{}", req.url()); + let Ok(parsed) = url::Url::parse(&full) else { + let _ = req.respond(html(400, "不正なリクエストです")); + continue; + }; + let q: HashMap<_, _> = parsed.query_pairs().into_owned().collect(); + + if let Some(err) = q.get("error") { + let _ = req.respond(html(400, "認証がキャンセルされました。")); + return Err(format!("認証エラー: {err}")); + } + let (Some(code), Some(state)) = (q.get("code"), q.get("state")) else { + let _ = req.respond(html(404, "このURLは認証用ではありません。")); + continue; + }; + // CSRF 対策: state が一致しない応答は捨てる + if state != expected_state { + let _ = req.respond(html(400, "state が一致しません。もう一度お試しください。")); + return Err("state 不一致(CSRFの可能性)".into()); + } + let _ = req.respond(html(200, "連携が完了しました。このタブを閉じてください。")); + return Ok(code.clone()); + } +} + +fn html(status: u16, msg: &str) -> tiny_http::Response>> { + let body = format!( + "FastTask\ + \ +

{msg}

" + ); + tiny_http::Response::from_string(body) + .with_status_code(status) + .with_header( + tiny_http::Header::from_bytes(&b"Content-Type"[..], &b"text/html; charset=utf-8"[..]) + .unwrap(), + ) +} + +async fn fetch_email(access: &str) -> Option { + let v: Value = http() + .get(USERINFO_ENDPOINT) + .bearer_auth(access) + .send() + .await + .ok()? + .json() + .await + .ok()?; + v.get("email").and_then(Value::as_str).map(str::to_string) +} + +pub async fn disconnect(app: tauri::AppHandle) -> Result { + let mut cfg = load_config(&app)?; + if !cfg.refresh_token.is_empty() { + // 失敗しても手元を消すのが優先(結果は無視) + let _ = http() + .post(REVOKE_ENDPOINT) + .form(&[("token", cfg.refresh_token.as_str())]) + .send() + .await; + } + cfg.refresh_token.clear(); + cfg.email.clear(); + save_config(&app, &cfg)?; + *ACCESS.lock().unwrap() = None; + // 対応表も捨てる(次の接続先が別アカウントなら無意味なため) + let _ = save_map(&app, &IdMap::new()); + Ok((&cfg).into()) +} + +// ---------------------------------------------------------------- access token + +static ACCESS: Mutex> = Mutex::new(None); + +fn cache_access(token: &str, expires_in: u64) { + let ttl = Duration::from_secs(expires_in.saturating_sub(60).max(30)); + *ACCESS.lock().unwrap() = Some((token.to_string(), Instant::now() + ttl)); +} + +fn http() -> reqwest::Client { + reqwest::Client::new() +} + +async fn access_token(cfg: &GoogleConfig) -> Result { + if let Some((tok, exp)) = ACCESS.lock().unwrap().as_ref() { + if Instant::now() < *exp { + return Ok(tok.clone()); + } + } + if cfg.refresh_token.is_empty() { + return Err("Googleに接続していません".into()); + } + let v: Value = http() + .post(TOKEN_ENDPOINT) + .form(&[ + ("client_id", cfg.client_id.as_str()), + ("client_secret", cfg.client_secret.as_str()), + ("refresh_token", cfg.refresh_token.as_str()), + ("grant_type", "refresh_token"), + ]) + .send() + .await + .map_err(|_| "トークン更新に失敗しました(ネットワーク)".to_string())? + .json() + .await + .map_err(|_| "トークン応答の解析に失敗しました".to_string())?; + if let Some(err) = v.get("error").and_then(Value::as_str) { + return Err(format!("トークン更新エラー: {err}(再接続が必要かもしれません)")); + } + let tok = v + .get("access_token") + .and_then(Value::as_str) + .ok_or("access_token が返りませんでした")?; + cache_access(tok, v.get("expires_in").and_then(Value::as_u64).unwrap_or(3000)); + Ok(tok.to_string()) +} + +// ---------------------------------------------------------------- push + +#[derive(Serialize, Default)] +pub struct PushResult { + pub pushed: usize, + pub skipped: usize, + pub errors: Vec, +} + +fn s(t: &Value, k: &str) -> String { + t.get(k).and_then(Value::as_str).unwrap_or_default().to_string() +} + +fn hash_of(t: &Value) -> String { + let mut h = Sha256::new(); + for k in ["title", "status", "dueDate", "url"] { + h.update(s(t, k).as_bytes()); + h.update(b"\x1f"); + } + b64url(&h.finalize()) +} + +/// data.json の tasks 配列を Google に push する。 +pub async fn push(app: tauri::AppHandle, tasks: Vec) -> Result { + let cfg = load_config(&app)?; + let token = access_token(&cfg).await?; + let mut map = load_map(&app); + let mut res = PushResult::default(); + + // ponytail: タスクを逐次処理している(Google APIのレート制限を踏みにくい代わりに遅い)。 + // 件数が増えて体感が悪ければ join_all で並列化し、429 時に Retry-After を見る実装にする。 + for task in &tasks { + let id = s(task, "id"); + if id.is_empty() { + continue; + } + let h = hash_of(task); + let entry = map.entry(id.clone()).or_default(); + if entry.hash == h { + res.skipped += 1; + continue; + } + let mut ok = true; + if cfg.push_tasks { + if let Err(e) = sync_gtask(&cfg, &token, task, entry).await { + res.errors.push(e); + ok = false; + } + } + if cfg.push_calendar { + if let Err(e) = sync_event(&cfg, &token, task, entry).await { + res.errors.push(e); + ok = false; + } + } + if ok { + entry.hash = h; + res.pushed += 1; + } + } + + // ponytail: FastTask で削除されたタスクの Google 側は消さずに対応表だけ掃除する。 + // 削除まで同期するなら、ここで残った entry の event_id/gtask_id を DELETE すればよい。 + let alive: std::collections::HashSet = tasks.iter().map(|t| s(t, "id")).collect(); + map.retain(|k, _| alive.contains(k)); + + save_map(&app, &map)?; + Ok(res) +} + +/// 404/410 は「Google側で消された」扱いにして、呼び出し元が作り直せるようにする。 +async fn send_json( + req: reqwest::RequestBuilder, + token: &str, + body: Option<&Value>, +) -> Result, String> { + let req = req.bearer_auth(token); + let req = match body { + Some(b) => req.json(b), + None => req, + }; + let resp = req.send().await.map_err(|_| "Google API 通信失敗".to_string())?; + let status = resp.status(); + if status == reqwest::StatusCode::NOT_FOUND || status == reqwest::StatusCode::GONE { + return Ok(None); + } + if !status.is_success() { + let detail = resp + .json::() + .await + .ok() + .and_then(|v| { + v.pointer("/error/message") + .and_then(Value::as_str) + .map(str::to_string) + }) + .unwrap_or_else(|| status.to_string()); + return Err(format!("Google API {status}: {detail}")); + } + resp.json().await.map(Some).map_err(|e| format!("応答解析失敗: {e}")) +} + +async fn sync_gtask( + cfg: &GoogleConfig, + token: &str, + task: &Value, + entry: &mut MapEntry, +) -> Result<(), String> { + let done = s(task, "status") == "done"; + let mut body = json!({ + "title": s(task, "title"), + "status": if done { "completed" } else { "needsAction" }, + }); + if !done { + // needsAction に戻す時は completed を明示的に消す必要がある + body["completed"] = Value::Null; + } + let url = s(task, "url"); + if !url.is_empty() { + body["notes"] = json!(url); + } + let due = s(task, "dueDate"); + body["due"] = if due.is_empty() { + Value::Null + } else { + json!(format!("{due}T00:00:00.000Z")) + }; + + let list = urlencoding(&cfg.tasklist_id); + if !entry.gtask_id.is_empty() { + let url = format!("{TASKS_API}/lists/{list}/tasks/{}", urlencoding(&entry.gtask_id)); + if send_json(http().patch(&url), token, Some(&body)).await?.is_some() { + return Ok(()); + } + entry.gtask_id.clear(); // Google側で消えていたので作り直す + } + let url = format!("{TASKS_API}/lists/{list}/tasks"); + let created = send_json(http().post(&url), token, Some(&body)) + .await? + .ok_or("Tasks リストが見つかりません")?; + entry.gtask_id = s(&created, "id"); + Ok(()) +} + +async fn sync_event( + cfg: &GoogleConfig, + token: &str, + task: &Value, + entry: &mut MapEntry, +) -> Result<(), String> { + let due = s(task, "dueDate"); + let cal = urlencoding(&cfg.calendar_id); + + // 期日が無い / 外されたタスクはカレンダーに置かない + if due.is_empty() { + if !entry.event_id.is_empty() { + let url = format!("{CALENDAR_API}/{cal}/events/{}", urlencoding(&entry.event_id)); + let _ = send_json(http().delete(&url), token, None).await; + entry.event_id.clear(); + } + return Ok(()); + } + + let end = next_day(&due)?; + let mut body = json!({ + "summary": s(task, "title"), + "start": { "date": due }, + "end": { "date": end }, + }); + let url_field = s(task, "url"); + if !url_field.is_empty() { + body["description"] = json!(url_field); + } + + if !entry.event_id.is_empty() { + let url = format!("{CALENDAR_API}/{cal}/events/{}", urlencoding(&entry.event_id)); + if send_json(http().patch(&url), token, Some(&body)).await?.is_some() { + return Ok(()); + } + entry.event_id.clear(); + } + let url = format!("{CALENDAR_API}/{cal}/events"); + let created = send_json(http().post(&url), token, Some(&body)) + .await? + .ok_or("カレンダーが見つかりません")?; + entry.event_id = s(&created, "id"); + Ok(()) +} + +/// 終日イベントの end.date は排他なので翌日を渡す +fn next_day(iso: &str) -> Result { + let d = chrono::NaiveDate::parse_from_str(iso, "%Y-%m-%d") + .map_err(|_| format!("dueDate の形式が不正です: {iso}"))?; + Ok((d + chrono::Duration::days(1)).format("%Y-%m-%d").to_string()) +} + +fn urlencoding(s: &str) -> String { + url::form_urlencoded::byte_serialize(s.as_bytes()).collect() +} + +pub async fn list_tasklists(app: tauri::AppHandle) -> Result { + let cfg = load_config(&app)?; + let token = access_token(&cfg).await?; + let url = format!("{TASKS_API}/users/@me/lists"); + send_json(http().get(&url), &token, None) + .await? + .ok_or("Tasks リストを取得できませんでした".into()) +} + +// ---------------------------------------------------------------- commands + +#[tauri::command] +pub async fn google_get_config(app: tauri::AppHandle) -> Result { + Ok((&load_config(&app)?).into()) +} + +/// 空文字の client_secret は「変更なし」として扱い、保存済みの値を消さない。 +#[tauri::command] +pub async fn google_set_config( + app: tauri::AppHandle, + patch: Value, +) -> Result { + let mut cfg = load_config(&app)?; + let txt = |k: &str| patch.get(k).and_then(Value::as_str).map(str::trim).map(str::to_string); + if let Some(v) = txt("clientId") { + cfg.client_id = v; + } + if let Some(v) = txt("clientSecret") { + if !v.is_empty() { + cfg.client_secret = v; + } + } + if let Some(v) = txt("calendarId") { + cfg.calendar_id = if v.is_empty() { "primary".into() } else { v }; + } + if let Some(v) = txt("tasklistId") { + cfg.tasklist_id = if v.is_empty() { "@default".into() } else { v }; + } + for (key, field) in [ + ("autoPush", 0usize), + ("pushCalendar", 1), + ("pushTasks", 2), + ] { + if let Some(b) = patch.get(key).and_then(Value::as_bool) { + match field { + 0 => cfg.auto_push = b, + 1 => cfg.push_calendar = b, + _ => cfg.push_tasks = b, + } + } + } + save_config(&app, &cfg)?; + Ok((&cfg).into()) +} + +#[tauri::command] +pub async fn google_connect(app: tauri::AppHandle) -> Result { + connect(app).await +} + +#[tauri::command] +pub async fn google_disconnect(app: tauri::AppHandle) -> Result { + disconnect(app).await +} + +#[tauri::command] +pub async fn google_list_tasklists(app: tauri::AppHandle) -> Result { + list_tasklists(app).await +} + +/// 設定画面の「今すぐ同期」。data.json を読み直して全件 push する。 +#[tauri::command] +pub async fn google_push_now(app: tauri::AppHandle) -> Result { + let tasks = read_tasks(&app)?; + push(app, tasks).await +} + +fn read_tasks(app: &tauri::AppHandle) -> Result, String> { + let path = dir(app)?.join("data.json"); + if !path.exists() { + return Ok(vec![]); + } + let s = fs::read_to_string(&path).map_err(|e| format!("read error: {e}"))?; + let v: Value = serde_json::from_str(&s).map_err(|e| format!("parse error: {e}"))?; + Ok(v.get("tasks") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default()) +} + +// ---------------------------------------------------------------- auto push + +static PUSH_IN_FLIGHT: Mutex = Mutex::new(false); + +/// save_data から呼ばれる fire-and-forget の自動push。 +/// 失敗してもアプリ操作をブロックしない(ログに出すだけ)。 +pub fn auto_push(app: tauri::AppHandle, tasks: Vec) { + let Ok(cfg) = load_config(&app) else { return }; + if !cfg.auto_push || cfg.refresh_token.is_empty() { + return; + } + { + // ponytail: 実行中なら今回の保存分はスキップする(最新状態は次の保存で追いつく)。 + // 取りこぼしを無くすなら「実行中に来たら1回だけ再実行する」フラグを足す。 + let mut flag = PUSH_IN_FLIGHT.lock().unwrap(); + if *flag { + return; + } + *flag = true; + } + tauri::async_runtime::spawn(async move { + match push(app, tasks).await { + Ok(r) if !r.errors.is_empty() => { + eprintln!("google auto push: {} 件失敗: {:?}", r.errors.len(), r.errors) + } + Err(e) => eprintln!("google auto push: {e}"), + _ => {} + } + *PUSH_IN_FLIGHT.lock().unwrap() = false; + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn check() { + // 終日イベントの end は翌日(排他) + assert_eq!(next_day("2026-11-10").unwrap(), "2026-11-11"); + assert_eq!(next_day("2026-12-31").unwrap(), "2027-01-01"); + assert!(next_day("11/10").is_err()); + + // 内容が変われば hash が変わる = 差分検出が効く + let a = json!({"title": "a", "status": "todo"}); + let b = json!({"title": "a", "status": "done"}); + assert_eq!(hash_of(&a), hash_of(&json!({"title": "a", "status": "todo"}))); + assert_ne!(hash_of(&a), hash_of(&b)); + + // PublicConfig に秘密が漏れていないこと + let cfg = GoogleConfig { + client_secret: "secret".into(), + refresh_token: "refresh".into(), + ..Default::default() + }; + let json = serde_json::to_string(&PublicConfig::from(&cfg)).unwrap(); + assert!(!json.contains("secret") || !json.contains("refresh")); + assert!(!json.contains("\"refresh\"")); + assert!(!json.contains("\"secret\"")); + assert!(json.contains("\"connected\":true")); + assert!(json.contains("\"hasSecret\":true")); + + // PKCE challenge は RFC7636 の既知ベクタと一致する + let v = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; + assert_eq!( + b64url(&Sha256::digest(v.as_bytes())), + "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" + ); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a144be9..a509a67 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,3 +1,6 @@ +mod api; +mod google; + use std::fs; use tauri::{Emitter, Manager}; use tauri::menu::{MenuBuilder, MenuItemBuilder, PredefinedMenuItem, SubmenuBuilder}; @@ -57,6 +60,11 @@ async fn save_data(app: tauri::AppHandle, data: serde_json::Value) -> Result<(), let s = serde_json::to_string_pretty(&data).map_err(|e| format!("serialize error: {e}"))?; fs::write(&tmp, s).map_err(|e| format!("write error: {e}"))?; fs::rename(&tmp, &path).map_err(|e| format!("rename error: {e}"))?; + + // Google への自動push(設定ONかつ接続済みのときだけ)。失敗しても保存は成功扱い。 + if let Some(tasks) = data.get("tasks").and_then(|v| v.as_array()) { + google::auto_push(app.clone(), tasks.clone()); + } Ok(()) } @@ -108,13 +116,21 @@ pub fn run() { } }); + api::start(app.handle().clone()); + Ok(()) }) .invoke_handler(tauri::generate_handler![ load_data, save_data, register_focus_shortcut, - unregister_focus_shortcut + unregister_focus_shortcut, + google::google_get_config, + google::google_set_config, + google::google_connect, + google::google_disconnect, + google::google_list_tasklists, + google::google_push_now ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src/App.tsx b/src/App.tsx index f7d9134..86e14ea 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -14,8 +14,8 @@ import { t } from "./i18n"; import { buildDfsOrder, getDepth, hasUndoneDescendants } from "./utils/taskTree"; import { getCurrentWindow } from "@tauri-apps/api/window"; import { LogicalSize } from "@tauri-apps/api/dpi"; -import { CalendarPanel } from "./components/CalendarPanel"; import { listen } from "@tauri-apps/api/event"; +import { invoke } from "@tauri-apps/api/core"; import { openUrl } from "@tauri-apps/plugin-opener"; import { CloseConfirmModal } from "./components/CloseConfirmModal"; import { checkForUpdate, UpdateInfo } from "./utils/updater"; @@ -26,7 +26,6 @@ function AppInner() { const loaded = useStore((s) => s.loaded); const grouping = useStore((s) => s.settings.groupingEnabled); const tagsEnabled = useStore((s) => s.settings.tagsEnabled ?? false); - const calendarEnabled = useStore((s) => s.settings.calendarEnabled ?? false); const tags = useStore((s) => s.tags); const lang = useStore((s) => s.settings.language); const lastFocusMinutes = useStore((s) => s.settings.lastFocusMinutes); @@ -182,12 +181,11 @@ function AppInner() { const scaleFactor = await win.scaleFactor(); const logical = (await win.innerSize()).toLogical(scaleFactor); const DEFAULT_WIDTH = 480; - const targetWidth = calendarEnabled ? Math.round(DEFAULT_WIDTH * 2.5) : DEFAULT_WIDTH; - if (Math.round(logical.width) !== targetWidth) { - await win.setSize(new LogicalSize(targetWidth, logical.height)); + if (Math.round(logical.width) !== DEFAULT_WIDTH) { + await win.setSize(new LogicalSize(DEFAULT_WIDTH, logical.height)); } })(); - }, [calendarEnabled]); + }, []); useEffect(() => { if (!loaded) return; @@ -212,9 +210,22 @@ function AppInner() { } }).then((f) => { unlistenUpdate = f; }); + // ローカルAPI/MCP が data.json を直接書き換えたときの再読み込み + let unlistenExternal: (() => void) | undefined; + listen("external-data-changed", async () => { + const raw = await invoke<{ tasks?: unknown[]; tags?: unknown[] } | null>("load_data"); + if (raw) { + useStore.setState({ + tasks: (raw.tasks ?? []) as never, + tags: (raw.tags ?? []) as never, + }); + } + }).then((f) => { unlistenExternal = f; }); + return () => { unlistenSettings?.(); unlistenUpdate?.(); + unlistenExternal?.(); }; }, []); @@ -777,11 +788,6 @@ function AppInner() { {!focusedTaskId && } - {calendarEnabled && ( -
- -
- )} setShowSettings(false)} /> {showShortcuts && setShowShortcuts(false)} />} diff --git a/src/components/CalendarPanel.tsx b/src/components/CalendarPanel.tsx deleted file mode 100644 index 5675043..0000000 --- a/src/components/CalendarPanel.tsx +++ /dev/null @@ -1,271 +0,0 @@ -import { useEffect, useMemo, useState } from "react"; -import { useStore } from "../store"; -import { useUiContext } from "../context/UiContext"; -import { t } from "../i18n"; -import { dateToIso, DAY_NAMES_JA, DAY_NAMES_EN_SHORT } from "../utils/parseDate"; - -type ViewMode = "month" | "week"; - -function startOfWeek(date: Date): Date { - const d = new Date(date); - d.setHours(0, 0, 0, 0); - d.setDate(d.getDate() - d.getDay()); - return d; -} - -function addDays(date: Date, days: number): Date { - const d = new Date(date); - d.setDate(d.getDate() + days); - return d; -} - -function addMonths(date: Date, months: number): Date { - const d = new Date(date); - d.setMonth(d.getMonth() + months); - return d; -} - -function startOfMonth(date: Date): Date { - return new Date(date.getFullYear(), date.getMonth(), 1); -} - -function endOfMonth(date: Date): Date { - return new Date(date.getFullYear(), date.getMonth() + 1, 0); -} - -function isSameDay(a: Date, b: Date): boolean { - return ( - a.getFullYear() === b.getFullYear() && - a.getMonth() === b.getMonth() && - a.getDate() === b.getDate() - ); -} - -export function CalendarPanel() { - const tasks = useStore((s) => s.tasks); - const toggleTask = useStore((s) => s.toggleTask); - const lang = useStore((s) => s.settings.language); - const { setPendingMainInput, selectedTaskId, calendarJumpIso, setCalendarJumpIso } = useUiContext(); - - const [anchorDate, setAnchorDate] = useState(() => new Date()); - const [viewMode, setViewMode] = useState("month"); - const [weekdaysOnly, setWeekdaysOnly] = useState(false); - const today = new Date(); - - // タスク追加/更新で確定した期限にカレンダーの表示月をジャンプさせる - useEffect(() => { - if (!calendarJumpIso) return; - setAnchorDate(new Date(calendarJumpIso + "T00:00:00")); - setCalendarJumpIso(null); - }, [calendarJumpIso, setCalendarJumpIso]); - - const tasksByDate = useMemo(() => { - const map = new Map(); - for (const task of tasks) { - if (!task.dueDate) continue; - const list = map.get(task.dueDate) ?? []; - list.push(task); - map.set(task.dueDate, list); - } - return map; - }, [tasks]); - - const dayNames = - lang === "ja" - ? DAY_NAMES_JA - : DAY_NAMES_EN_SHORT.map((d) => d[0].toUpperCase() + d.slice(1)); - const visibleDayIndexes = weekdaysOnly ? [1, 2, 3, 4, 5] : [0, 1, 2, 3, 4, 5, 6]; - - const weeks = useMemo(() => { - if (viewMode === "week") { - return [Array.from({ length: 7 }, (_, i) => addDays(startOfWeek(anchorDate), i))]; - } - const monthStart = startOfMonth(anchorDate); - const monthEnd = endOfMonth(anchorDate); - const gridStart = startOfWeek(monthStart); - const gridEnd = addDays(startOfWeek(monthEnd), 6); - const days: Date[] = []; - for (let cur = gridStart; cur <= gridEnd; cur = addDays(cur, 1)) { - days.push(cur); - } - const rows: Date[][] = []; - for (let i = 0; i < days.length; i += 7) rows.push(days.slice(i, i + 7)); - return rows; - }, [viewMode, anchorDate]); - - function goPrev() { - setAnchorDate((d) => (viewMode === "month" ? addMonths(d, -1) : addDays(d, -7))); - } - function goNext() { - setAnchorDate((d) => (viewMode === "month" ? addMonths(d, 1) : addDays(d, 7))); - } - function goToday() { - setAnchorDate(new Date()); - } - - function openMainInputFor(day: Date) { - setPendingMainInput(` ${day.getMonth() + 1}/${day.getDate()}`); - } - - const headerLabel = - viewMode === "month" - ? lang === "ja" - ? `${anchorDate.getFullYear()}年${anchorDate.getMonth() + 1}月` - : anchorDate.toLocaleDateString("en-US", { month: "long", year: "numeric" }) - : (() => { - const s = startOfWeek(anchorDate); - const e = addDays(s, 6); - return `${s.getMonth() + 1}/${s.getDate()} - ${e.getMonth() + 1}/${e.getDate()}`; - })(); - - return ( -
-
-
-
{headerLabel}
-
- - - -
-
-
-
- {(["month", "week"] as ViewMode[]).map((m) => ( - - ))} -
- -
-
- -
- {visibleDayIndexes.map((i) => ( -
- {dayNames[i]} -
- ))} -
- -
- {weeks.map((week, wi) => ( -
- {visibleDayIndexes.map((di) => { - const day = week[di]; - const iso = dateToIso(day); - const isCurrentMonth = viewMode === "week" || day.getMonth() === anchorDate.getMonth(); - const isToday = isSameDay(day, today); - const dayTasks = isToday - ? [ - ...(tasksByDate.get(iso) ?? []), - ...tasks.filter((tk) => tk.isMinimum && tk.dueDate !== iso), - ] - : tasksByDate.get(iso) ?? []; - const visibleTasks = viewMode === "week" ? dayTasks : dayTasks.slice(0, 3); - const overflow = viewMode === "week" ? 0 : dayTasks.length - visibleTasks.length; - - return ( -
openMainInputFor(day)} - className={`${viewMode === "week" ? "h-full flex flex-col" : "min-h-[64px]"} rounded-md p-1 text-left cursor-text transition-colors ${ - isToday - ? "bg-black/[0.04] hover:bg-black/[0.06] dark:bg-white/[0.05] dark:hover:bg-white/[0.07]" - : "hover:bg-black/[0.03] dark:hover:bg-white/[0.04]" - } ${isCurrentMonth ? "" : "opacity-40"}`} - > -
{day.getDate()}
-
- {visibleTasks.map((task) => { - const label = - task.projectName && task.title.startsWith(`:${task.projectName} `) - ? task.title.slice(task.projectName.length + 2) - : task.title; - return ( -
e.stopPropagation()} - className={`flex items-center gap-1 rounded px-0.5 -mx-0.5 ${ - selectedTaskId === task.id ? "bg-black/10 dark:bg-white/15" : "" - }`} - > - - - {label} - -
- ); - })} - {overflow > 0 &&
+{overflow}
} -
-
- ); - })} -
- ))} -
-
- ); -} diff --git a/src/components/GoogleSyncSettings.tsx b/src/components/GoogleSyncSettings.tsx new file mode 100644 index 0000000..22bbd6f --- /dev/null +++ b/src/components/GoogleSyncSettings.tsx @@ -0,0 +1,336 @@ +import { useEffect, useState } from "react"; +import { openUrl } from "@tauri-apps/plugin-opener"; +import { invoke } from "@tauri-apps/api/core"; +import { Language } from "../types"; + +/** Rust 側 PublicConfig と対。secret / refresh_token は一切含まれない。 */ +interface GoogleConfig { + connected: boolean; + hasSecret: boolean; + clientId: string; + email: string; + calendarId: string; + tasklistId: string; + autoPush: boolean; + pushCalendar: boolean; + pushTasks: boolean; +} + +interface PushResult { + pushed: number; + skipped: number; + errors: string[]; +} + +interface TaskList { + id: string; + title: string; +} + +// SettingsModal と同じく、この画面のテキストはコンポーネント内に持つ +/** セットアップ手順は GitHub Pages で配信(docs/ を scripts/build-docs.sh でビルド) */ +const SETUP_GUIDE_URL = "https://riiimparm.github.io/FastTask/google-sync/"; + +const T = { + ja: { + heading: "Google連携", + desc: "FastTask のタスクを Google カレンダー / Google ToDo に書き出します(書き込みのみ・取り込みはしません)", + clientId: "クライアントID", + clientSecret: "クライアントシークレット", + secretSaved: "保存済み(変更する場合のみ入力)", + setupHint: "GCPで発行した OAuth クライアント(デスクトップアプリ)の値を入力してください。", + setupLink: "セットアップ手順を開く", + connect: "Googleに接続", + connecting: "ブラウザで認証中...", + disconnect: "接続を解除", + connectedAs: "接続中", + notConnected: "未接続", + calendarId: "カレンダーID", + tasklistId: "ToDoリスト", + fetchLists: "リスト取得", + autoPush: "自動書き出し", + autoPushDesc: "タスクの追加・更新時に自動でGoogleへ書き出す", + pushCalendar: "カレンダーに書き出す", + pushCalendarDesc: "期日のあるタスクを終日イベントとして作成", + pushTasks: "ToDoに書き出す", + pushTasksDesc: "タスクを Google ToDo に作成(完了状態も反映)", + syncNow: "今すぐ同期", + syncing: "同期中...", + saved: "保存しました", + result: (r: PushResult) => `書き出し ${r.pushed} 件 / 変更なし ${r.skipped} 件`, + needClientId: "先にクライアントIDを保存してください", + save: "保存", + }, + en: { + heading: "Google sync", + desc: "Pushes FastTask tasks to Google Calendar / Google Tasks (write only, never imports)", + clientId: "Client ID", + clientSecret: "Client secret", + secretSaved: "Saved (enter only to change)", + setupHint: "Enter the OAuth client (Desktop app) values issued in GCP.", + setupLink: "Open the setup guide", + connect: "Connect to Google", + connecting: "Authorizing in browser...", + disconnect: "Disconnect", + connectedAs: "Connected", + notConnected: "Not connected", + calendarId: "Calendar ID", + tasklistId: "Task list", + fetchLists: "Fetch lists", + autoPush: "Auto push", + autoPushDesc: "Push to Google automatically when a task is added or updated", + pushCalendar: "Push to Calendar", + pushCalendarDesc: "Create an all-day event for tasks with a due date", + pushTasks: "Push to Tasks", + pushTasksDesc: "Create tasks in Google Tasks (completion is mirrored)", + syncNow: "Sync now", + syncing: "Syncing...", + saved: "Saved", + result: (r: PushResult) => `${r.pushed} pushed / ${r.skipped} unchanged`, + needClientId: "Save a client ID first", + save: "Save", + }, +} as const; + +function Row({ label, desc, children }: { label: string; desc?: string; children: React.ReactNode }) { + return ( +
+
+
{label}
+ {desc &&
{desc}
} +
+ {children} +
+ ); +} + +function MiniToggle({ value, onChange, disabled }: { value: boolean; onChange: (v: boolean) => void; disabled?: boolean }) { + return ( + + ); +} + +const inputCls = + "px-2 py-1.5 text-[12px] rounded-md border border-black/10 dark:border-white/10 bg-transparent outline-none focus:border-accent w-full"; +const btnCls = + "px-3 py-1.5 text-[12px] rounded-md bg-black/8 hover:bg-black/15 dark:bg-white/10 dark:hover:bg-white/20 disabled:opacity-40 shrink-0"; + +export function GoogleSyncSettings({ lang }: { lang: Language }) { + const tx = T[lang] ?? T.ja; + const [cfg, setCfg] = useState(null); + const [clientId, setClientId] = useState(""); + const [clientSecret, setClientSecret] = useState(""); + const [calendarId, setCalendarId] = useState("primary"); + const [tasklistId, setTasklistId] = useState("@default"); + const [lists, setLists] = useState(null); + const [busy, setBusy] = useState<"" | "connect" | "sync" | "lists">(""); + const [msg, setMsg] = useState(""); + const [err, setErr] = useState(""); + + function apply(c: GoogleConfig) { + setCfg(c); + setClientId(c.clientId); + setCalendarId(c.calendarId); + setTasklistId(c.tasklistId); + setClientSecret(""); + } + + useEffect(() => { + invoke("google_get_config").then(apply).catch((e) => setErr(String(e))); + }, []); + + async function patch(p: Record) { + setErr(""); + try { + apply(await invoke("google_set_config", { patch: p })); + setMsg(tx.saved); + } catch (e) { + setErr(String(e)); + } + } + + async function run(kind: "connect" | "sync" | "lists", fn: () => Promise) { + setBusy(kind); + setErr(""); + setMsg(""); + try { + await fn(); + } catch (e) { + setErr(String(e)); + } finally { + setBusy(""); + } + } + + if (!cfg) return null; + + return ( +
+
+
{tx.heading}
+
{tx.desc}
+
+ +
+
{tx.clientId}
+
+ setClientId(e.target.value)} + /> + +
+
{tx.clientSecret}
+ setClientSecret(e.target.value)} + /> +
+ {tx.setupHint}{" "} + +
+
+ +
+
+ {cfg.connected ? ( + + {tx.connectedAs}: {cfg.email || "Google"} + + ) : ( + {tx.notConnected} + )} +
+ {cfg.connected ? ( + + ) : ( + + )} +
+ + {cfg.connected && ( +
+ + patch({ pushTasks: v })} /> + + + patch({ pushCalendar: v })} /> + + + patch({ autoPush: v })} /> + + +
+
{tx.calendarId}
+
+ setCalendarId(e.target.value)} + onBlur={() => calendarId !== cfg.calendarId && patch({ calendarId })} + /> +
+
{tx.tasklistId}
+
+ {lists ? ( + + ) : ( + setTasklistId(e.target.value)} + onBlur={() => tasklistId !== cfg.tasklistId && patch({ tasklistId })} + /> + )} + +
+
+ + +
+ )} + + {msg &&
{msg}
} + {err &&
{err}
} +
+ ); +} diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index c0e19a2..06e0072 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { invoke } from "@tauri-apps/api/core"; import { useStore } from "../store"; import { TagManager } from "./TagManager"; +import { GoogleSyncSettings } from "./GoogleSyncSettings"; import { t, TKey } from "../i18n"; import { Language, Theme } from "../types"; @@ -10,7 +11,7 @@ interface Props { onClose: () => void; } -type ToggleKey = "copyGroupingEnabled" | "autoDeleteOldCompleted" | "showDueDate" | "tagsEnabled" | "calendarEnabled" | "urlEnabled"; +type ToggleKey = "copyGroupingEnabled" | "autoDeleteOldCompleted" | "showDueDate" | "tagsEnabled" | "urlEnabled" | "soundEnabled"; const TOGGLES: Array<{ key: ToggleKey; label: TKey; desc: TKey }> = [ { key: "urlEnabled", label: "setting_urlEnabled", desc: "setting_urlEnabled_desc" }, @@ -18,7 +19,7 @@ const TOGGLES: Array<{ key: ToggleKey; label: TKey; desc: TKey }> = [ { key: "autoDeleteOldCompleted", label: "setting_autoDeleteOldCompleted", desc: "setting_autoDeleteOldCompleted_desc" }, { key: "showDueDate", label: "setting_showDueDate", desc: "setting_showDueDate_desc" }, { key: "tagsEnabled", label: "setting_tagsEnabled", desc: "setting_tagsEnabled_desc" }, - { key: "calendarEnabled", label: "setting_calendarEnabled", desc: "setting_calendarEnabled_desc" }, + { key: "soundEnabled", label: "setting_soundEnabled", desc: "setting_soundEnabled_desc" }, ]; function Toggle({ value, onChange }: { value: boolean; onChange: (v: boolean) => void }) { @@ -288,6 +289,10 @@ export function SettingsModal({ open, onClose }: Props) { +
+ +
+ {settings.tagsEnabled && (
diff --git a/src/components/TaskInput.tsx b/src/components/TaskInput.tsx index e5d6b75..ca5f13b 100644 --- a/src/components/TaskInput.tsx +++ b/src/components/TaskInput.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { useStore } from "../store"; +import { playKeystroke } from "../utils/sound"; import { useUiContext } from "../context/UiContext"; import { parseProjectFromInput } from "../utils/project"; import { parseDateFromText, formatDateShort, dateToIso, isWithinDueWindow } from "../utils/parseDate"; @@ -32,22 +33,8 @@ export function TaskInput() { searchQuery, setSearchMode, setSearchQuery, - pendingMainInput, - setPendingMainInput, - setCalendarJumpIso, } = useUiContext(); - // カレンダーの日付セルクリックで日付付きテキストを差し込み、日付の手前にカーソルを置く - useEffect(() => { - if (pendingMainInput === null) return; - setValue(pendingMainInput); - setPendingMainInput(null); - requestAnimationFrame(() => { - inputRef.current?.focus(); - inputRef.current?.setSelectionRange(0, 0); - }); - }, [pendingMainInput, setPendingMainInput]); - // mainInputRefとinputRefを同期 useEffect(() => { mainInputRef.current = inputRef.current; @@ -128,8 +115,17 @@ export function TaskInput() { if (id) lastId = id; }); setValue(""); - if (lastId) setSelectedTaskId(lastId); - requestAnimationFrame(() => inputRef.current?.focus()); + // 作成直後は生成されたアイテムを選択し、入力欄からフォーカスを外す + if (lastId) { + setSelectedTaskId(lastId); + inputRef.current?.blur(); + // アイテムは次のレンダー後に現れるので描画を待ってからDOMフォーカスを移す + requestAnimationFrame(() => { + document.querySelector(`[data-task-id="${lastId}"]`)?.focus(); + }); + } else { + requestAnimationFrame(() => inputRef.current?.focus()); + } } function submit() { @@ -155,8 +151,11 @@ export function TaskInput() { finalizeSubmit(parts, iso, firstProject); } + const soundEnabled = useStore((s) => s.settings.soundEnabled); + function onKeyDown(e: React.KeyboardEvent) { if (e.nativeEvent.isComposing || e.keyCode === 229) return; + if (soundEnabled) playKeystroke(e); if (e.key === "Escape" && searchMode) { e.preventDefault(); @@ -362,7 +361,6 @@ export function TaskInput() { const p = pendingSubmit; setPendingSubmit(null); finalizeSubmit(p.parts, p.iso, p.firstProject); - if (p.iso) setCalendarJumpIso(p.iso); }} /> )} diff --git a/src/components/TaskItem.tsx b/src/components/TaskItem.tsx index e7b3258..c74281c 100644 --- a/src/components/TaskItem.tsx +++ b/src/components/TaskItem.tsx @@ -3,6 +3,7 @@ import { useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { openUrl } from "@tauri-apps/plugin-opener"; import { useStore } from "../store"; +import { playKeystroke } from "../utils/sound"; import { useUiContext } from "../context/UiContext"; import { Tag, Task } from "../types"; import { Popover } from "./Popover"; @@ -32,9 +33,10 @@ export function TaskItem({ task, draggable = true }: Props) { const urlEnabled = settings.urlEnabled ?? true; const depth = useMemo(() => getDepth(task, allTasks), [task, allTasks]); + const soundEnabled = useStore((s) => s.settings.soundEnabled); const isLocked = useMemo(() => hasUndoneDescendants(task.id, allTasks), [task.id, allTasks]); - const { selectedTaskId, focusedTaskId, isReorderMode, bulkSelected, setSelectedTaskId, vibratingTaskId, setCalendarJumpIso, completingTaskIds } = useUiContext(); + const { selectedTaskId, focusedTaskId, isReorderMode, bulkSelected, setSelectedTaskId, vibratingTaskId, completingTaskIds } = useUiContext(); const isSelected = selectedTaskId === task.id; const isFocused = focusedTaskId === task.id; const isBulkSelected = bulkSelected.has(task.id); @@ -156,8 +158,10 @@ export function TaskItem({ task, draggable = true }: Props) { }} style={{ ...style, paddingLeft: depth > 0 ? `${depth * 20 + 8}px` : undefined }} data-project={task.projectName || undefined} + data-task-id={task.id} + tabIndex={-1} onClick={() => setSelectedTaskId(task.id)} - className={`relative group task-item ${task.projectName ? "" : "task-item-plain"} px-2 py-1.5 flex items-center gap-2 transition-all rounded-md + className={`relative group task-item focus:outline-none ${task.projectName ? "" : "task-item-plain"} px-2 py-1.5 flex items-center gap-2 transition-all rounded-md border-l-[3px] ${vibratingTaskId === task.id ? "task-shake" : ""} ${isCompleting ? "task-sweep-left" : ""} @@ -194,7 +198,7 @@ export function TaskItem({ task, draggable = true }: Props) {