diff --git a/docs/collection-reset.md b/docs/collection-reset.md new file mode 100644 index 0000000..2f96bd4 --- /dev/null +++ b/docs/collection-reset.md @@ -0,0 +1,33 @@ +# PCとモバイルのコレクションリセット同期 + +## 問題と修正 + +従来のPCリセットはローカル配列のみを消し、モバイル削除送信関数は未使用だった。旧削除通知も一回限りでACKがなく、完了結果ジャーナルから報酬が再生される問題があった。 + +リセット時はPCの永続ストレージへ `sourceId` と累積 `measurementIds` を先に保存する。保存失敗時はリセットを中止し表示する。保存後にローカル表示を消し、既存の2秒間隔再送処理が `emit_acquired_characters_cleared({reset})` を呼ぶ。PCを途中で終了しても、起動時のコレクション読み込みが削除IDを除外し、リセット記録を再送する。測定履歴そのものは消さない。 + +WSには `collection_reset`(eventId/sequence/requiresAck)と `collectionReset: {sourceId, measurementIds}` を追加。snapshotとイベント再送にも最新記録を含め、ACK後も再接続で復元する。モバイルは削除IDの永続化→コレクション再構築・保存→ACK(status=stored)の順。保存途中の失敗はACKせず、次回配送・起動で修復する。新しい測定IDの報酬は古いリセットの再送で消えない。 + +モバイルは結果履歴からの復元にも同じ削除フィルターを適用する。新規データにはPC sourceIdを付け、別PCの記録を保持する。旧カードにsourceIdがない場合はPCが列挙した測定IDの一致だけで削除対象とする(他PCと同一IDを手動作成した旧データは区別できない)。削除IDは意図的に累積保持し、日時比較・端末時計には依存しない。 + +旧 `acquired_characters_cleared` は使用しない。旧モバイルが遅延再送を全件削除として扱うことを避けるため。新旧混在では旧モバイルのリセット同期は未対応であり、両方の更新が必要。 + +## 検証 + +- PC `bun run build`、Rust `cargo check`、Rustテスト4件(実HTTP/WebSocket接続、オフラインリセット→snapshot→再送→ACK→再接続を含む)通過。 +- mobile lint、TypeScript、pairing 8件、contracts 3件通過。 +- 保存失敗・部分保存からの復旧・プロセス再起動・結果再送・二重リセット・リセット後の新規獲得・別PCの報酬保持をメモリストレージで検証。実際のユーザーデータのリセットは未実施。 +- ソケット試験はこのMac上。実iPhoneの同一LAN、ロック・アプリ強制終了・TestFlightインストールでの操作は未検証。 + +## 実機受け入れ手順 + +PC 0.1.4 と対応する新TestFlightビルドに更新し、Metroを停止した状態で行う。コレクションリセットは実データを消すため、消してよいテスト用の獲得で実施する。 + +1. PCとiPhoneをペアリングし獲得。PCでリセットし、前景のモバイル一覧と開いていた詳細が更新されること。 +2. iPhoneをロックしてPCでリセット。PCはモバイルを待たず完了。解除・アプリ復帰で消去されること。 +3. Wi-Fi切断中にもリセット。復旧・再接続、さらにモバイル強制終了後の起動でも古いカードが戻らないこと。 +4. PCも終了・再起動し必要なら再ペアリング。リセットが保持されること。 +5. 新しく測定して同じキャラクターを再獲得し、再接続しても1件だけ残ること。 +6. 過去の測定時間・姿勢結果が履歴として残り、進行中の別測定が古い通知で終了しないこと。 + +画面ロック中の即時実行・通知は保証しない。復帰時のLAN同期によって反映する。公開/インストール完了と実機検証完了は分けて記録する。 diff --git a/package.json b/package.json index c9337c7..682c6a2 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "posture-app", "private": true, - "version": "0.1.3", + "version": "0.1.5", "type": "module", "scripts": { "dev": "vite", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 5aabc14..bd2b4a8 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2586,7 +2586,7 @@ dependencies = [ [[package]] name = "posture-app" -version = "0.1.3" +version = "0.1.5" dependencies = [ "arboard", "base64 0.22.1", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index cfc2b9a..0520c1d 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "posture-app" -version = "0.1.3" +version = "0.1.5" description = "A Tauri App" authors = ["you"] edition = "2021" diff --git a/src-tauri/src/commands/pairing_commands.rs b/src-tauri/src/commands/pairing_commands.rs index 97109f5..45ed8dc 100644 --- a/src-tauri/src/commands/pairing_commands.rs +++ b/src-tauri/src/commands/pairing_commands.rs @@ -90,10 +90,8 @@ pub fn emit_posture_signal(is_bad: bool, state: State<'_, PairingStateHandle>) { } #[tauri::command] -pub fn emit_acquired_characters_cleared(state: State<'_, PairingStateHandle>) { - state.clear_pending_acquired_events(); - state.bump_sequence(); - broadcast_ws_state_event(&state, "acquired_characters_cleared"); +pub fn emit_acquired_characters_cleared(reset: crate::pairing::CollectionReset, state: State<'_, PairingStateHandle>) { + crate::pairing::broadcast_ws_collection_reset(&state, reset); } #[tauri::command] diff --git a/src-tauri/src/pairing/mod.rs b/src-tauri/src/pairing/mod.rs index 2567575..14a6468 100644 --- a/src-tauri/src/pairing/mod.rs +++ b/src-tauri/src/pairing/mod.rs @@ -3,11 +3,11 @@ mod state; mod types; pub use server::{ - broadcast_ws_acquired_event, broadcast_ws_completed_event, broadcast_ws_state_event, + broadcast_ws_collection_reset, broadcast_ws_acquired_event, broadcast_ws_completed_event, broadcast_ws_state_event, disconnect_ws_clients, start_pairing_server, ws_connected_client_count, }; pub use state::{ - AcquiredCharacterPayload, CharacterColorPayload, CompletedMeasurement, PairingStateHandle, + CollectionReset, AcquiredCharacterPayload, CharacterColorPayload, CompletedMeasurement, PairingStateHandle, PostureTimelineSegmentPayload, }; pub use types::{DesktopPairingStatus, PairingInfo}; diff --git a/src-tauri/src/pairing/server.rs b/src-tauri/src/pairing/server.rs index 69f920b..bd7515e 100644 --- a/src-tauri/src/pairing/server.rs +++ b/src-tauri/src/pairing/server.rs @@ -259,6 +259,15 @@ pub fn broadcast_ws_acquired_event(state: &PairingStateHandle, payload: Acquired } } +pub fn broadcast_ws_collection_reset(state: &PairingStateHandle, reset: super::state::CollectionReset) { + let event = state.create_collection_reset_event(reset); + state.mark_acquired_event_sent(&event.event_id); + if let Some(sink) = WS_SINK.get() { + let sink = sink.clone(); + thread::spawn(move || broadcast_ws_event(&sink, &event)); + } +} + pub fn broadcast_ws_completed_event( state: &PairingStateHandle, result: super::state::CompletedMeasurement, @@ -757,6 +766,30 @@ mod tests { socket.write_all(&frame(json!({"type":"ping"}))).unwrap(); while event(&mut socket)["type"] != "pong" {} assert!(state.build_pending_resend_events().is_empty()); + // Phone offline during reset: snapshot and reliable replay both carry durable reset IDs. + socket.shutdown(std::net::Shutdown::Both).unwrap(); + broadcast_ws_collection_reset(&state, super::super::state::CollectionReset { + source_id: "test-pc".into(), measurement_ids: vec!["test-measurement".into()], + }); + let mut socket = ws(info.port, &info.token); + let snapshot = event(&mut socket); + assert_eq!(snapshot["collectionReset"]["measurementIds"][0], "test-measurement"); + assert_eq!(snapshot["measurementId"], "measurement-B"); + assert_eq!(snapshot["measuringSessionActive"], true); + let reset = event(&mut socket); + assert_eq!(reset["type"], "collection_reset"); + assert_eq!(reset["requiresAck"], true); + assert!(!state.ack_event(reset["eventId"].as_str().unwrap(), 0)); + let retry = state.build_retry_due_events(timestamp_string().parse::().unwrap() + 6, 5, 5); + assert!(retry.iter().any(|event| event.event_id == reset["eventId"].as_str().unwrap())); + socket.write_all(&frame(json!({"type":"ack_event", "ackEventId":reset["eventId"], "ackSequence":reset["sequence"], "status":"stored"}))).unwrap(); + socket.write_all(&frame(json!({"type":"ping"}))).unwrap(); + while event(&mut socket)["type"] != "pong" {} + assert!(state.build_pending_resend_events().is_empty()); + // ACK must not remove the reset from snapshots for a later app launch. + socket.shutdown(std::net::Shutdown::Both).unwrap(); + let mut socket = ws(info.port, &info.token); + assert_eq!(event(&mut socket)["collectionReset"]["measurementIds"][0], "test-measurement"); let mut http = connect(info.port); write!( http, diff --git a/src-tauri/src/pairing/state.rs b/src-tauri/src/pairing/state.rs index 66eb602..7e21e32 100644 --- a/src-tauri/src/pairing/state.rs +++ b/src-tauri/src/pairing/state.rs @@ -30,6 +30,7 @@ struct PairingState { /// PC で良い姿勢登録(キャリブレーション)中は true(スマホの登録中 UI と同期) good_posture_registration_active: bool, pending_acks: HashMap, + collection_reset: Option, } #[derive(Clone)] @@ -90,6 +91,14 @@ pub struct WsEvent { measuring_session_active: bool, is_bad_posture: bool, good_posture_registration_active: bool, + collection_reset: Option, +} + +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CollectionReset { + pub source_id: String, + pub measurement_ids: Vec, } #[derive(Clone, Debug, Serialize, Deserialize)] @@ -168,6 +177,7 @@ pub struct ReliableWsEvent { good_posture_registration_active: bool, payload: Option, result: Option, + collection_reset: Option, } impl PairingStateHandle { @@ -186,6 +196,7 @@ impl PairingStateHandle { is_bad_posture: false, good_posture_registration_active: false, pending_acks: HashMap::new(), + collection_reset: None, })), } } @@ -327,6 +338,7 @@ impl PairingStateHandle { let good_posture_registration_active = state.good_posture_registration_active; let event = ReliableWsEvent { r#type: event_type.to_string(), + collection_reset: state.collection_reset.clone(), event_id: event_id.clone(), sequence, requires_ack: true, @@ -396,6 +408,7 @@ impl PairingStateHandle { } ReliableWsEvent { r#type: record.event_type.clone(), + collection_reset: state.collection_reset.clone(), event_id: record.event_id.clone(), sequence: record.sequence, requires_ack: true, @@ -458,6 +471,7 @@ impl PairingStateHandle { } due_events.push(ReliableWsEvent { r#type: record.event_type.clone(), + collection_reset: state.collection_reset.clone(), event_id: record.event_id, sequence: record.sequence, requires_ack: true, @@ -484,21 +498,27 @@ impl PairingStateHandle { } } - pub fn bump_sequence(&self) { - let mut state = self.inner.lock().expect("pairing state poisoned"); - state.last_sequence += 1; - } - - /** PC 側でコレクションを全消去したとき、モバイルへ同期し保留中の獲得イベントも破棄する */ - pub fn clear_pending_acquired_events(&self) { - let mut state = self.inner.lock().expect("pairing state poisoned"); - state.pending_acks.clear(); + pub fn create_collection_reset_event(&self, mut reset: CollectionReset) -> ReliableWsEvent { + { + let mut state = self.inner.lock().expect("pairing state poisoned"); + if let Some(previous) = &state.collection_reset { + if previous.source_id == reset.source_id { + reset.measurement_ids.extend(previous.measurement_ids.clone()); + } + } + reset.measurement_ids.sort(); + reset.measurement_ids.dedup(); + state.collection_reset = Some(reset); + } + // Keep completed results: only collection ownership is reset, not measurement history. + self.create_reliable_event("collection_reset", None, None) } pub fn build_ws_event(&self, event_type: &str) -> WsEvent { let state = self.inner.lock().expect("pairing state poisoned"); WsEvent { r#type: event_type.to_string(), + collection_reset: state.collection_reset.clone(), sequence: state.last_sequence, paired: state.paired, device_name: state.device_name.clone(), diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index d9d6265..8ad238d 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "PiiiN", - "version": "0.1.3", + "version": "0.1.5", "identifier": "com.vyuma.posture-app", "build": { "beforeDevCommand": "bun run dev", diff --git a/src/App.tsx b/src/App.tsx index 10a385c..c9165ae 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,4 @@ -import { saveCompletedMeasurement, readCompletedMeasurements, publishCompletedMeasurement } from "./features/pairing/services/measurementDelivery"; +import { saveCompletedMeasurement, readCompletedMeasurements, publishCompletedMeasurement, resetCollection, readCollectionReset, publishCollectionReset } from "./features/pairing/services/measurementDelivery"; import type { AcquiredCharacterEventInput } from "./features/pairing/services/desktopBridge"; import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; @@ -174,12 +174,18 @@ function App() { const finishedMeasurementRef = useRef(null); useEffect(() => { const accepted = new Set(); + let acceptedReset = ""; let running = false; let cancelled = false; const replay = async () => { if (running) return; running = true; try { + const reset = readCollectionReset(); + if (reset && JSON.stringify(reset) !== acceptedReset) { + await publishCollectionReset(reset); + acceptedReset = JSON.stringify(reset); + } for (const result of readCompletedMeasurements()) { if (cancelled) break; if (!accepted.has(result.id)) { @@ -521,6 +527,13 @@ function App() { ); const handleDebugClearAcquiredCharacters = useCallback(() => { + try { + resetCollection(acquiredCharacters.map(card => card.measurementId)); + } catch (error) { + console.error(error); + setPermissionPopupMessage("リセットを保存できませんでした。空き容量を確認して再試行してください。"); + return; + } clearAcquiredCharacters(); saveAcquiredCharacters([]); setAcquiredCharacters([]); @@ -530,7 +543,7 @@ function App() { saveFavoriteCharacterIds(new Set()); setCollectionResetTick((current) => current + 1); saveSelectedProfileCharacterId(null); - }, []); + }, [acquiredCharacters]); const handleCompleteOnboardingStory = useCallback(() => { saveOnboardingStoryCompleted(); diff --git a/src/features/characters/characterStorage.ts b/src/features/characters/characterStorage.ts index 261bbe4..4cbe900 100644 --- a/src/features/characters/characterStorage.ts +++ b/src/features/characters/characterStorage.ts @@ -1,3 +1,4 @@ +import { readCollectionReset } from "./collectionResetStorage"; import type { AcquiredCharacter } from "./types"; import type { PostureTimelineSegment } from "../flow/types"; import { normalizeCharacterId } from "./characterIds"; @@ -17,7 +18,7 @@ export function loadAcquiredCharacters(): AcquiredCharacter[] { } return dedupeAcquiredCharacters( - parsedValue.filter(isAcquiredCharacter).map(normalizeAcquiredCharacter), + parsedValue.filter(isAcquiredCharacter).filter(card => !readCollectionReset()?.measurementIds.includes(card.measurementId)).map(normalizeAcquiredCharacter), ); } catch { return []; diff --git a/src/features/characters/collectionResetStorage.ts b/src/features/characters/collectionResetStorage.ts new file mode 100644 index 0000000..f6cfe3e --- /dev/null +++ b/src/features/characters/collectionResetStorage.ts @@ -0,0 +1,15 @@ +/** Cumulative tombstones: an old delivery cannot restore a reset reward. */ +export type CollectionReset = { sourceId: string; measurementIds: string[] }; +const KEY = "posture.mobile.collection-reset.v1"; +export function readCollectionReset(): CollectionReset | null { + const raw = localStorage.getItem(KEY); + if (!raw) return null; + const value = JSON.parse(raw); + if (!value.sourceId || !Array.isArray(value.measurementIds) || !value.measurementIds.every((id: unknown) => typeof id === "string")) { + throw new Error("コレクションのリセット記録を読み込めません。"); + } + return value; +} +export function saveCollectionReset(reset: CollectionReset): void { + localStorage.setItem(KEY, JSON.stringify(reset)); +} diff --git a/src/features/pairing/services/desktopBridge.ts b/src/features/pairing/services/desktopBridge.ts index ac06386..5f21291 100644 --- a/src/features/pairing/services/desktopBridge.ts +++ b/src/features/pairing/services/desktopBridge.ts @@ -50,10 +50,6 @@ export type AcquiredCharacterEventInput = { postureTimeline?: { startMs: number; endMs: number; isGood: boolean }[]; }; -export async function sendAcquiredCharactersCleared(): Promise { - await invoke("emit_acquired_characters_cleared"); -} - export async function sendAcquiredCharacterEvent( input: AcquiredCharacterEventInput, ): Promise { diff --git a/src/features/pairing/services/measurementDelivery.ts b/src/features/pairing/services/measurementDelivery.ts index 53a66c2..9001cc5 100644 --- a/src/features/pairing/services/measurementDelivery.ts +++ b/src/features/pairing/services/measurementDelivery.ts @@ -1,3 +1,4 @@ +import { readCollectionReset, saveCollectionReset, type CollectionReset } from "../../characters/collectionResetStorage"; import type { MeasurementResult } from "../../flow/types"; import type { AcquiredCharacterEventInput } from "./desktopBridge"; import { invoke } from "@tauri-apps/api/core"; @@ -37,3 +38,21 @@ export function readCompletedMeasurements(): CompletedMeasurement[] { export async function publishCompletedMeasurement(result: CompletedMeasurement): Promise { await invoke("emit_completed_measurement", { result }); } + +// Persist before clearing local UI; startup filtering completes an interrupted reset. +export function resetCollection(measurementIds: string[]): CollectionReset { + const records = readCompletedMeasurements(); + const previous = readCollectionReset(); + const sourceId = cachedSourceId ?? localStorage.getItem(SOURCE_KEY) ?? crypto.randomUUID(); + localStorage.setItem(SOURCE_KEY, sourceId); + cachedSourceId = sourceId; + const reset = { sourceId, measurementIds: [...new Set([ + ...(previous?.measurementIds ?? []), ...measurementIds, ...records.map(result => result.id), + ])] }; + saveCollectionReset(reset); + return reset; +} +export async function publishCollectionReset(reset: CollectionReset): Promise { + await invoke("emit_acquired_characters_cleared", { reset }); +} +export { readCollectionReset };