Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions docs/collection-reset.md
Original file line number Diff line number Diff line change
@@ -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同期によって反映する。公開/インストール完了と実機検証完了は分けて記録する。
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "posture-app",
"private": true,
"version": "0.1.3",
"version": "0.1.5",
"type": "module",
"scripts": {
"dev": "vite",
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "posture-app"
version = "0.1.3"
version = "0.1.5"
description = "A Tauri App"
authors = ["you"]
edition = "2021"
Expand Down
6 changes: 2 additions & 4 deletions src-tauri/src/commands/pairing_commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
4 changes: 2 additions & 2 deletions src-tauri/src/pairing/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
33 changes: 33 additions & 0 deletions src-tauri/src/pairing/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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::<u64>().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,
Expand Down
38 changes: 29 additions & 9 deletions src-tauri/src/pairing/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ struct PairingState {
/// PC で良い姿勢登録(キャリブレーション)中は true(スマホの登録中 UI と同期)
good_posture_registration_active: bool,
pending_acks: HashMap<String, PendingAckRecord>,
collection_reset: Option<CollectionReset>,
}

#[derive(Clone)]
Expand Down Expand Up @@ -90,6 +91,14 @@ pub struct WsEvent {
measuring_session_active: bool,
is_bad_posture: bool,
good_posture_registration_active: bool,
collection_reset: Option<CollectionReset>,
}

#[derive(Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CollectionReset {
pub source_id: String,
pub measurement_ids: Vec<String>,
}

#[derive(Clone, Debug, Serialize, Deserialize)]
Expand Down Expand Up @@ -168,6 +177,7 @@ pub struct ReliableWsEvent {
good_posture_registration_active: bool,
payload: Option<AcquiredCharacterPayload>,
result: Option<CompletedMeasurement>,
collection_reset: Option<CollectionReset>,
}

impl PairingStateHandle {
Expand All @@ -186,6 +196,7 @@ impl PairingStateHandle {
is_bad_posture: false,
good_posture_registration_active: false,
pending_acks: HashMap::new(),
collection_reset: None,
})),
}
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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(),
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
17 changes: 15 additions & 2 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -174,12 +174,18 @@ function App() {
const finishedMeasurementRef = useRef<string | null>(null);
useEffect(() => {
const accepted = new Set<string>();
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)) {
Expand Down Expand Up @@ -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([]);
Expand All @@ -530,7 +543,7 @@ function App() {
saveFavoriteCharacterIds(new Set());
setCollectionResetTick((current) => current + 1);
saveSelectedProfileCharacterId(null);
}, []);
}, [acquiredCharacters]);

const handleCompleteOnboardingStory = useCallback(() => {
saveOnboardingStoryCompleted();
Expand Down
3 changes: 2 additions & 1 deletion src/features/characters/characterStorage.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { readCollectionReset } from "./collectionResetStorage";
import type { AcquiredCharacter } from "./types";
import type { PostureTimelineSegment } from "../flow/types";
import { normalizeCharacterId } from "./characterIds";
Expand All @@ -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 [];
Expand Down
15 changes: 15 additions & 0 deletions src/features/characters/collectionResetStorage.ts
Original file line number Diff line number Diff line change
@@ -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));
}
4 changes: 0 additions & 4 deletions src/features/pairing/services/desktopBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,6 @@ export type AcquiredCharacterEventInput = {
postureTimeline?: { startMs: number; endMs: number; isGood: boolean }[];
};

export async function sendAcquiredCharactersCleared(): Promise<void> {
await invoke("emit_acquired_characters_cleared");
}

export async function sendAcquiredCharacterEvent(
input: AcquiredCharacterEventInput,
): Promise<void> {
Expand Down
19 changes: 19 additions & 0 deletions src/features/pairing/services/measurementDelivery.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -37,3 +38,21 @@ export function readCompletedMeasurements(): CompletedMeasurement[] {
export async function publishCompletedMeasurement(result: CompletedMeasurement): Promise<void> {
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<void> {
await invoke("emit_acquired_characters_cleared", { reset });
}
export { readCollectionReset };
Loading