From 1179d2fa7ab6e1ac68b4cb3027fc3b44632330af Mon Sep 17 00:00:00 2001 From: bafv4 Date: Fri, 28 Aug 2026 23:04:17 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat(preview):=20=E3=82=A8=E3=83=AA?= =?UTF-8?q?=E3=82=A2=E3=81=AE=E8=A4=87=E6=95=B0=E9=81=B8=E6=8A=9E=E3=81=A8?= =?UTF-8?q?=E3=81=BE=E3=81=A8=E3=82=81=E3=81=A6=E7=A7=BB=E5=8B=95=E3=81=AB?= =?UTF-8?q?=E5=AF=BE=E5=BF=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ctrl/⌘+クリックで main / locked / preparing を複数選択でき、選択中のどのエリアから ドラッグしても選択全体がまとめて動く。スナップと中央ぞろえの指示線は個々のエリアでは なく**選択全体の外接矩形**の辺・中央で判定する。リサイズハンドルは単独選択時のみ。 - 移動は「floor した外接矩形の変位」を整数化してから全メンバーへ同量加算する剛体移動。 相対位置が 1px も崩れず、±MAX_DIMENSION のクランプ境界でも移動量側を制限して グループが変形しない。座標系の不変条件(整数のみ)は従来どおり store 側で担保。 - store に moveAreas を追加し、グループ移動は 1 回の set() でまとめて反映 (pointermove ごとに N 回の再レンダリング・永続化が走らないように)。 - Shift はトグルに使わない。従来の「ドラッグ中のスナップ無効」に割当済みで、 両方に割り当てると Shift+ドラッグ開始が不能になるため(レビューで検出し修正)。 - 複数選択のメンバーを移動なしでクリックしたら単独選択へ縮退(開始閾値 3px)。 preparing の選択参照は index ベースのため、削除で別エリアに付け替わらないよう 配列が縮んだら preparing の選択を落とす。 - boundingBox は「座標・スケールは core/coords.ts に集約」の規約に従い core に配置。 - 検証: tsc / eslint 0 エラー。コードレビュー(8 アングル)で 10 件検出し全件修正。 実装関数(boundingBox/snapMove/floorCell)を合成したアルゴリズム検証 5 件通過 (剛体性・bbox 中央への磁着・個別エリア中央では磁着しないこと・境界クランプ・ 広い選択の寸法表示)。ブラウザペイン非表示の制約で実ポインタ操作の目視は未実施。 Co-Authored-By: Claude Fable 5 --- src/components/WallPreview.tsx | 488 +++++++++++++++++++++++++++------ src/core/coords.ts | 20 ++ src/i18n/locales/en.json | 2 +- src/i18n/locales/ja.json | 2 +- src/store/useWallStore.ts | 43 +++ 5 files changed, 465 insertions(+), 90 deletions(-) diff --git a/src/components/WallPreview.tsx b/src/components/WallPreview.tsx index 46427eb..9b5eb4a 100644 --- a/src/components/WallPreview.tsx +++ b/src/components/WallPreview.tsx @@ -8,8 +8,12 @@ * カラーピッカーのドラッグ 1 イベントごとに MB 級の再確保とフル解像度の再合成が走る。 * エクスポートは `buildPack` が独立にフル解像度で描くのでプレビュー品質とは無関係。 * - エリア(main / locked / preparing[i])はドラッグ移動・8 ハンドルでリサイズ。 - * - 選択中の画像レイヤ(`fit:'manual'`)も同様に move/resize。 + * Ctrl/⌘+クリックで複数選択でき、選択中のエリアはドラッグでまとめて移動する + * (Shift はドラッグ中のスナップ無効に割当済みのため、トグルには使わない)。 + * リサイズハンドルは単独選択のときだけ表示する(グループリサイズは非対応)。 + * - 選択中の画像レイヤ(`fit:'manual'`)も同様に move/resize(レイヤは複数選択の対象外)。 * - スナップ: 他エリア辺・中央・キャンバス端と中央へ磁着(プレビュー基準 6px)。Shift で無効化。 + * 複数選択の移動では**選択全体の外接矩形**の辺・中央で判定する(指示線も同じ基準)。 * - ドラッグ中は `x,y / w×h` のオーバーレイ表示。 * * 不変条件: 全 state mutation は store アクション経由(座標は store 側で `floorArea`/`floorCell`)。 @@ -26,7 +30,13 @@ import { type PointerEvent as ReactPointerEvent, } from 'react'; import { useTranslation } from 'react-i18next'; -import { floorCell, realToPreview } from '../core/coords'; +import { + MAX_DIMENSION, + MIN_COORDINATE, + boundingBox, + floorCell, + realToPreview, +} from '../core/coords'; import { renderBackgroundToCanvas } from '../core/renderBackground'; import type { AreaCell, @@ -36,7 +46,7 @@ import type { VisibleArea, WallState, } from '../core/state'; -import { useWallStore } from '../store/useWallStore'; +import { useWallStore, type AreaMove } from '../store/useWallStore'; import { snapMove, snapResize, type Handle } from './snap'; import { cn } from './ui/cn'; @@ -52,14 +62,65 @@ type AreaRef = type Mode = { type: 'move' } | { type: 'resize'; handle: Handle }; -interface DragState { +/** 選択集合の membership 判定・スナップ候補の除外に使う安定キー。 */ +function areaRefKey(ref: AreaRef): string { + switch (ref.kind) { + case 'main': + return 'main'; + case 'locked': + return 'locked'; + case 'preparing': + return `preparing:${ref.index}`; + case 'layer': + return `layer:${ref.layerId}`; + } +} + +// 描画・選択判定で使い回す固定参照(毎レンダーの再生成とキー書式の手書きを避ける) +const MAIN_REF: AreaRef = { kind: 'main' }; +const LOCKED_REF: AreaRef = { kind: 'locked' }; +const MAIN_KEY = areaRefKey(MAIN_REF); +const LOCKED_KEY = areaRefKey(LOCKED_REF); + +/** + * 複数選択ドラッグの開始閾値(CSS px)。この距離を超えるまでは store に反映しない。 + * 超えずに離した場合は「クリック」とみなし、押したメンバーの単独選択へ縮退する + * (選択済みメンバーの上でも通常クリックで単独選択に戻れるようにするため)。 + */ +const GROUP_DRAG_START_THRESHOLD_CSS = 3; + +interface DragItem { ref: AreaRef; + startCell: AreaCell; +} + +interface DragState { mode: Mode; + /** move: 選択中の全エリア(1..N)。resize / layer: 必ず 1 件。 */ + items: DragItem[]; + /** + * pointerdown されたエリア。move かつ複数選択で、閾値を超えずに離された + * 「クリック」のとき、このエリアの単独選択へ縮退させる。 + */ + pressedRef: AreaRef | null; + /** 複数選択の move が開始閾値を一度でも超えたか。 */ + moved: boolean; + /** + * ドラッグ中は不変な派生値。開始時と startCellRefreshed の読み直し後に + * initDragDerived で再計算する(pointermove ごとの再確保を避ける)。 + * - bbox0: 開始時セル群の外接矩形(move のスナップ・移動量の基準) + * - maxStartX/Y: メンバー x/y の最大値。クランプ境界(±MAX_DIMENSION)でも + * グループの剛体性が壊れないよう、移動量の上限を導くのに使う + * - cand: スナップ候補(移動中メンバーを除外済み。他エリアはドラッグ中に変化しない) + */ + bbox0: AreaCell; + maxStartX: number; + maxStartY: number; + cand: { xs: number[]; ys: number[] }; startClientX: number; startClientY: number; - startCell: AreaCell; /** - * startCell を最初の pointermove で store から読み直したか。 + * items の startCell を最初の pointermove で store から読み直したか。 * 数値入力欄の blur コミットは pointerdown の後・最初の pointermove の前に発火するため、 * pointerdown 時に捕捉した startCell はコミット直前の旧値であり得る。 * そのまま全フィールドを dispatch すると、blur でコミットした値を旧値で巻き戻してしまう。 @@ -68,7 +129,7 @@ interface DragState { pxToRealX: number; pxToRealY: number; /** - * 直前に dispatch した整数セルとスナップヒット。 + * 直前に dispatch した整数セル(move では選択全体の外接矩形)とスナップヒット。 * store の `mergeAreaPatch` は常に新しい area/layout オブジェクトを作るため、 * 整数化した結果が前回と同じでも再レンダリング+永続化まで走ってしまう。 * 主に効くのは、スナップ線に磁着している間(閾値内のポインタ移動はすべて @@ -234,6 +295,30 @@ function sameCell(a: AreaCell | null, b: AreaCell): boolean { ); } +/** + * resize / move 共通の dedupe。前回 dispatch した結果と比較し、変化があれば + * 記録して true を返す。false のときは store 更新もオーバーレイ更新も不要 + * (store 側は floorArea するので、floor 後が同じなら state は 1bit も変わらない)。 + */ +function rememberDragResult( + drag: DragState, + cell: AreaCell, + hitX: number | null, + hitY: number | null, +): boolean { + if ( + sameCell(drag.lastCell, cell) && + drag.lastHitX === hitX && + drag.lastHitY === hitY + ) { + return false; + } + drag.lastCell = cell; + drag.lastHitX = hitX; + drag.lastHitY = hitY; + return true; +} + // --------------------------------------------------------------------------- // プレビューサイズ // --------------------------------------------------------------------------- @@ -260,12 +345,13 @@ interface AreaBoxProps { resolution: Resolution; preview: Resolution; selected: boolean; + /** 選択がこの 1 件だけか。リサイズハンドルは selected && soleSelection のときだけ出す。 */ + soleSelection: boolean; onPointerDownArea: ( e: ReactPointerEvent, refId: AreaRef, mode: Mode, ) => void; - onSelect: (refId: AreaRef) => void; } function AreaBox({ @@ -276,9 +362,11 @@ function AreaBox({ resolution, preview, selected, + soleSelection, onPointerDownArea, - onSelect, }: AreaBoxProps) { + // グループリサイズは非対応なので、ハンドルは単独選択のときだけ表示する + const showHandles = selected && soleSelection; const pv = useMemo( () => realToPreview(area, { real: resolution, preview }), [area, resolution, preview], @@ -329,7 +417,7 @@ function AreaBox({ }} onPointerDown={(e) => { if (e.button !== 0) return; - onSelect(refId); + // 選択の更新(トグル/単独化)とドラッグ開始は親がまとめて判断する onPointerDownArea(e, refId, { type: 'move' }); }} > @@ -340,7 +428,7 @@ function AreaBox({ {label} {gridLines} - {selected && + {showHandles && HANDLE_LIST.map((h) => (
s.setLocked); const updatePreparing = useWallStore((s) => s.updatePreparing); const updateBackgroundLayer = useWallStore((s) => s.updateBackgroundLayer); + const moveAreas = useWallStore((s) => s.moveAreas); const containerRef = useRef(null); const dragRef = useRef(null); const [preview, setPreview] = useState({ width: 0, height: 0 }); - const [selected, setSelected] = useState({ kind: 'main' }); + + // 選択集合(1 件以上)。Ctrl/⌘+クリックでトグル、通常クリックで単独選択。 + const [rawSelected, setRawSelected] = useState([MAIN_REF]); + // 非表示化・削除で消えたエリアを選択から除く。空になったら main に戻す + // (既存挙動: 空クリックでも main が選択される=常に何かが選択されている)。 + const selectedRefs = useMemo(() => { + const alive = rawSelected.filter((r) => { + if (r.kind === 'main') return true; + if (r.kind === 'locked') return layout.locked.show; + if (r.kind === 'preparing') + return layout.preparing[r.index]?.show === true; + return false; // layer は複数選択の対象外 + }); + return alive.length > 0 ? alive : [MAIN_REF]; + }, [rawSelected, layout.locked.show, layout.preparing]); + const selectedKeys = useMemo( + () => new Set(selectedRefs.map(areaRefKey)), + [selectedRefs], + ); + + // preparing の選択参照は index ベースなので、配列が縮むと別エリアを指してしまう + // (#1 を消すと旧 #2 が index 0 になり、選択が黙って付け替わる)。エリアに安定 ID が + // 無い以上ここでは追跡できないため、削除を検知したら preparing の選択を全て落とす。 + const prevPreparingLen = useRef(layout.preparing.length); + useEffect(() => { + const prev = prevPreparingLen.current; + prevPreparingLen.current = layout.preparing.length; + if (layout.preparing.length < prev) { + setRawSelected((sel) => { + const kept = sel.filter((r) => r.kind !== 'preparing'); + return kept.length === sel.length ? sel : kept; + }); + } + }, [layout.preparing.length]); /** ドラッグ中のオーバーレイ(px 表示・スナップ ヒット線)。null = 操作なし。 */ const [dragOverlay, setDragOverlay] = useState<{ @@ -483,19 +605,21 @@ export function WallPreview() { }, [resolution]); // ---- スナップ候補(他エリア+キャンバス端) ---- + // 移動中のエリア(複数選択なら選択中の全エリア)を候補から除く。 const buildSnapCandidates = useCallback( - (movingRef: AreaRef): { xs: number[]; ys: number[] } => { + (excludeKeys: ReadonlySet): { xs: number[]; ys: number[] } => { const xs: number[] = [0, resolution.width / 2, resolution.width]; const ys: number[] = [0, resolution.height / 2, resolution.height]; const addCell = (c: AreaCell) => { xs.push(c.x, c.x + c.width / 2, c.x + c.width); ys.push(c.y, c.y + c.height / 2, c.y + c.height); }; - if (movingRef.kind !== 'main') addCell(layout.main); - if (movingRef.kind !== 'locked' && layout.locked.show) + if (!excludeKeys.has(MAIN_KEY)) addCell(layout.main); + if (!excludeKeys.has(LOCKED_KEY) && layout.locked.show) addCell(layout.locked); layout.preparing.forEach((p, i) => { - if (movingRef.kind === 'preparing' && movingRef.index === i) return; + if (excludeKeys.has(areaRefKey({ kind: 'preparing', index: i }))) + return; if (!p.show) return; addCell(p); }); @@ -558,21 +682,58 @@ export function WallPreview() { // ---- pointer handlers ---- - const onPointerDownArea = useCallback( + /** + * items からドラッグ中不変の派生値(外接矩形・メンバー最大座標・スナップ候補)を + * 再計算して drag に書き込む。開始時と startCellRefreshed の読み直し後に呼ぶ。 + */ + const initDragDerived = useCallback( + (drag: Pick): Pick< + DragState, + 'bbox0' | 'maxStartX' | 'maxStartY' | 'cand' + > => { + const cells = drag.items.map((i) => i.startCell); + const bbox0 = boundingBox(cells); + let maxStartX = -Infinity; + let maxStartY = -Infinity; + for (const c of cells) { + maxStartX = Math.max(maxStartX, c.x); + maxStartY = Math.max(maxStartY, c.y); + } + return { + bbox0, + maxStartX, + maxStartY, + cand: buildSnapCandidates( + new Set(drag.items.map((i) => areaRefKey(i.ref))), + ), + }; + }, + [buildSnapCandidates], + ); + + /** ドラッグを開始する。move では items が選択中の全エリア、それ以外は 1 件。 */ + const startDrag = useCallback( ( e: ReactPointerEvent, - refId: AreaRef, + refs: AreaRef[], mode: Mode, + pressedRef: AreaRef | null = null, ) => { if (preview.width === 0 || preview.height === 0) return; - const startCell = getStartCell(refId); - if (!startCell) return; + const items: DragItem[] = []; + for (const ref of refs) { + const startCell = getStartCell(ref); + if (startCell) items.push({ ref, startCell }); + } + if (items.length === 0) return; dragRef.current = { - ref: refId, mode, + items, + pressedRef, + moved: false, + ...initDragDerived({ items, mode }), startClientX: e.clientX, startClientY: e.clientY, - startCell, startCellRefreshed: false, pxToRealX: resolution.width / preview.width, pxToRealY: resolution.height / preview.height, @@ -580,10 +741,51 @@ export function WallPreview() { lastHitX: null, lastHitY: null, }; - setDragOverlay({ cell: startCell, hitX: null, hitY: null }); + setDragOverlay({ + cell: dragRef.current.bbox0, + hitX: null, + hitY: null, + }); e.currentTarget.setPointerCapture(e.pointerId); }, - [preview, resolution, getStartCell], + [preview, resolution, getStartCell, initDragDerived], + ); + + const onPointerDownArea = useCallback( + ( + e: ReactPointerEvent, + refId: AreaRef, + mode: Mode, + ) => { + if (mode.type === 'resize') { + // ハンドルは単独選択のときしか表示されないが、防御的に単独化しておく + setRawSelected([refId]); + startDrag(e, [refId], mode); + return; + } + const key = areaRefKey(refId); + if (e.ctrlKey || e.metaKey) { + // トグルのみ(このジェスチャではドラッグを開始しない)。 + // Shift は「ドラッグ中のスナップ無効」に割当済みなのでトグルには使わない。 + // Shift+pointerdown をトグルにすると、ヒントに書かれた + // 「Shift を押しながらドラッグ=スナップなし移動」が開始できなくなる。 + setRawSelected( + selectedKeys.has(key) + ? selectedRefs.filter((r) => areaRefKey(r) !== key) + : [...selectedRefs, refId], + ); + return; + } + if (selectedKeys.has(key)) { + // 選択済みメンバーの通常ドラッグ → 選択全体をまとめて移動。 + // 閾値を超えず離された「クリック」なら pressedRef の単独選択へ縮退する。 + startDrag(e, selectedRefs, mode, refId); + } else { + setRawSelected([refId]); + startDrag(e, [refId], mode); + } + }, + [selectedKeys, selectedRefs, startDrag], ); const onPointerDownLayer = useCallback( @@ -592,9 +794,10 @@ export function WallPreview() { layerId: string, mode: Mode, ) => { - onPointerDownArea(e, { kind: 'layer', layerId }, mode); + // レイヤは複数選択の対象外(常に単独ドラッグ) + startDrag(e, [{ kind: 'layer', layerId }], mode); }, - [onPointerDownArea], + [startDrag], ); const onPointerMove = useCallback( @@ -602,61 +805,163 @@ export function WallPreview() { const drag = dragRef.current; if (!drag) return; if (!drag.startCellRefreshed) { - // DragState.startCellRefreshed のコメント参照(blur コミット後の値で基準を取り直す) + // DragState.startCellRefreshed のコメント参照(blur コミット後の値で基準を取り直す)。 + // blur コミットはどのエリアの値も変えうるので、派生値(bbox0/候補)も作り直す。 drag.startCellRefreshed = true; - const fresh = getStartCell(drag.ref); - if (fresh) drag.startCell = fresh; + drag.items = drag.items.flatMap((item) => { + const fresh = getStartCell(item.ref); + return fresh ? [{ ref: item.ref, startCell: fresh }] : []; + }); + if (drag.items.length === 0) { + dragRef.current = null; + setDragOverlay(null); + return; + } + Object.assign(drag, initDragDerived(drag)); } - const dxReal = (e.clientX - drag.startClientX) * drag.pxToRealX; - const dyReal = (e.clientY - drag.startClientY) * drag.pxToRealY; - let nextCell: AreaCell; - if (drag.mode.type === 'move') { - nextCell = { - ...drag.startCell, - x: drag.startCell.x + dxReal, - y: drag.startCell.y + dyReal, - }; - } else { - nextCell = applyResize(drag.startCell, drag.mode.handle, dxReal, dyReal); + const dxCss = e.clientX - drag.startClientX; + const dyCss = e.clientY - drag.startClientY; + const dxReal = dxCss * drag.pxToRealX; + const dyReal = dyCss * drag.pxToRealY; + const thX = SNAP_PX_PREVIEW * drag.pxToRealX; + const thY = SNAP_PX_PREVIEW * drag.pxToRealY; + + if (drag.mode.type === 'resize') { + // リサイズは常に単独(ハンドルは単独選択時のみ表示) + const item = drag.items[0]; + let nextCell = applyResize( + item.startCell, + drag.mode.handle, + dxReal, + dyReal, + ); + let hitX: number | null = null; + let hitY: number | null = null; + if (!e.shiftKey) { + const result = snapResize( + nextCell, + drag.mode.handle, + drag.cand, + thX, + thY, + ); + nextCell = result.cell; + hitX = result.hitX; + hitY = result.hitY; + } + const nextFloored = floorCell(nextCell); + if (!rememberDragResult(drag, nextFloored, hitX, hitY)) return; + dispatchCell(item.ref, nextFloored); + setDragOverlay({ cell: nextFloored, hitX, hitY }); + return; + } + + // ---- move(1..N 件) ---- + // スナップ・指示線・オーバーレイは選択全体の外接矩形(bbox0)で判定する。 + // 開始セルは整数(store が floor 済み)なので bbox0 も整数。移動量を + // 「floor した外接矩形の変位」として整数化してから各エリアへ同量だけ足すことで、 + // グループ内の相対位置が 1px も崩れない剛体移動になる。 + if (drag.items.length > 1 && !drag.moved) { + // 複数選択では閾値を超えるまで store に反映しない。超えずに離されたら + // 「クリック」として onPointerUp が pressedRef の単独選択へ縮退させる。 + if ( + Math.abs(dxCss) < GROUP_DRAG_START_THRESHOLD_CSS && + Math.abs(dyCss) < GROUP_DRAG_START_THRESHOLD_CSS + ) { + return; + } } + drag.moved = true; + const bbox0 = drag.bbox0; + let movedBox: AreaCell = { + ...bbox0, + x: bbox0.x + dxReal, + y: bbox0.y + dyReal, + }; let hitX: number | null = null; let hitY: number | null = null; if (!e.shiftKey) { - const cand = buildSnapCandidates(drag.ref); - const thX = SNAP_PX_PREVIEW * drag.pxToRealX; - const thY = SNAP_PX_PREVIEW * drag.pxToRealY; - const result = - drag.mode.type === 'move' - ? snapMove(nextCell, cand, thX, thY) - : snapResize(nextCell, drag.mode.handle, cand, thX, thY); - nextCell = result.cell; + const result = snapMove(movedBox, drag.cand, thX, thY); + movedBox = result.cell; hitX = result.hitX; hitY = result.hitY; } - // 整数化してから前回と突き合わせる。同値なら store 更新もオーバーレイ更新も不要 - // (store 側は floorArea するので、floor 後が同じなら state は 1bit も変わらない)。 - const nextFloored = floorCell(nextCell); - if ( - sameCell(drag.lastCell, nextFloored) && - drag.lastHitX === hitX && - drag.lastHitY === hitY - ) { - return; - } - drag.lastCell = nextFloored; - drag.lastHitX = hitX; - drag.lastHitY = hitY; + // 移動量の整数化。さらに全メンバーが座標クランプ(±MAX_DIMENSION)の内側に + // 収まるよう移動量自体を制限する。制限せずに store 側で各メンバーが独立に + // クランプされると、境界に触れたメンバーだけ止まりグループが変形してしまう。 + const flooredBox = floorCell(movedBox); + const rawDx = flooredBox.x - bbox0.x; + const rawDy = flooredBox.y - bbox0.y; + const intDx = Math.max( + MIN_COORDINATE - bbox0.x, + Math.min(rawDx, MAX_DIMENSION - drag.maxStartX), + ); + const intDy = Math.max( + MIN_COORDINATE - bbox0.y, + Math.min(rawDy, MAX_DIMENSION - drag.maxStartY), + ); + // クランプで移動量が変わった軸は、スナップ位置に居ないので指示線を消す + if (intDx !== rawDx) hitX = null; + if (intDy !== rawDy) hitY = null; - dispatchCell(drag.ref, nextFloored); - setDragOverlay({ cell: nextFloored, hitX, hitY }); + // オーバーレイと dedupe は bbox0 の寸法をそのまま使う(floorCell の width + // クランプ(上限 MAX_DIMENSION)で、広い選択の寸法表示が誤らないように)。 + const finalBox: AreaCell = { + x: bbox0.x + intDx, + y: bbox0.y + intDy, + width: bbox0.width, + height: bbox0.height, + }; + if (!rememberDragResult(drag, finalBox, hitX, hitY)) return; + + if (drag.items.length === 1) { + // 単独はレイヤも含むので従来どおり dispatchCell(フル cell)で反映 + const item = drag.items[0]; + dispatchCell(item.ref, { + ...item.startCell, + x: item.startCell.x + intDx, + y: item.startCell.y + intDy, + }); + } else { + // グループは 1 回の set() でまとめて反映(AreaMove へ変換。layer は来ない) + moveAreas( + drag.items.flatMap((item): AreaMove[] => { + const x = item.startCell.x + intDx; + const y = item.startCell.y + intDy; + switch (item.ref.kind) { + case 'main': + return [{ kind: 'main', x, y }]; + case 'locked': + return [{ kind: 'locked', x, y }]; + case 'preparing': + return [{ kind: 'preparing', index: item.ref.index, x, y }]; + case 'layer': + return []; + } + }), + ); + } + setDragOverlay({ cell: finalBox, hitX, hitY }); }, - [buildSnapCandidates, dispatchCell, getStartCell], + [dispatchCell, moveAreas, getStartCell, initDragDerived], ); const onPointerUp = useCallback((e: ReactPointerEvent) => { - if (!dragRef.current) return; + const drag = dragRef.current; + if (!drag) return; + // 複数選択のメンバーを「クリック」(閾値未満で離した)→ そのエリアの単独選択へ。 + // 旧実装の「pointerdown したエリアが必ず単独選択になる」を、グループ移動と + // 両立する形(move が始まらなかったときだけ)で復元する。 + if ( + drag.mode.type === 'move' && + drag.items.length > 1 && + !drag.moved && + drag.pressedRef + ) { + setRawSelected([drag.pressedRef]); + } dragRef.current = null; setDragOverlay(null); if (e.currentTarget.hasPointerCapture(e.pointerId)) { @@ -670,6 +975,8 @@ export function WallPreview() { .map((p, index) => ({ p, index })) .filter(({ p }) => p.show); + const soleSelection = selectedRefs.length === 1; + // ドラッグオーバーレイ位置(プレビュー px) const overlayPv = useMemo(() => { if (!dragOverlay) return null; @@ -698,7 +1005,11 @@ export function WallPreview() { onPointerUp={onPointerUp} onPointerCancel={onPointerUp} onPointerDown={(e) => { - if (e.target === e.currentTarget) setSelected({ kind: 'main' }); + // 空クリックで main の単独選択に戻す(Ctrl/⌘ 中はトグル操作の + // 空振りとして選択を維持する。Shift はスナップ無効用なので対象外) + if (e.target === e.currentTarget && !e.ctrlKey && !e.metaKey) { + setRawSelected([MAIN_REF]); + } }} > {layout.locked.show && ( )} - {visiblePreparing.map(({ p, index }) => ( - - ))} + {visiblePreparing.map(({ p, index }) => { + const ref: AreaRef = { kind: 'preparing', index }; + return ( + + ); + })} {/* スナップヒット線 */} {hitLineX !== null && ( diff --git a/src/core/coords.ts b/src/core/coords.ts index e61db29..a885674 100644 --- a/src/core/coords.ts +++ b/src/core/coords.ts @@ -93,6 +93,26 @@ export function floorInt(n: number): number { return toSafeInt(n, 0, MIN_COORDINATE, MAX_DIMENSION); } +/** + * セル群の外接矩形。プレビューの複数選択ドラッグでは、スナップ・指示線・ + * オーバーレイ表示を個々のエリアではなく**選択全体**のこの矩形に対して行う。 + * 呼び出し側が 1 件以上を保証する。入力が整数(store は floorArea/floorCell 済み) + * なら結果も整数。クランプは行わない(移動量の導出に使うため、値を歪めない)。 + */ +export function boundingBox(cells: readonly AreaCell[]): AreaCell { + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + for (const c of cells) { + minX = Math.min(minX, c.x); + minY = Math.min(minY, c.y); + maxX = Math.max(maxX, c.x + c.width); + maxY = Math.max(maxY, c.y + c.height); + } + return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }; +} + export function floorCell(cell: T): T { return { ...cell, diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 699702f..28f293d 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -13,7 +13,7 @@ }, "preview": { "title": "Preview", - "hint": "Click an area to select. Drag to move; use corner / edge handles to resize. All values are stored as integers.", + "hint": "Click an area to select; Ctrl/⌘+click for multi-select. Drag to move (a multi-selection moves together, snapping against the selection's bounding box); use corner / edge handles to resize. All values are stored as integers.", "scale": "Real {{realWidth}}×{{realHeight}} / preview {{previewWidth}}×{{previewHeight}}", "snapHint": "Hold Shift to disable snapping" }, diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 58abefe..62b57bc 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -13,7 +13,7 @@ }, "preview": { "title": "プレビュー", - "hint": "エリアをクリックで選択。ドラッグで移動、角・辺のハンドルでリサイズできます。操作値はすべて整数で記録されます。", + "hint": "エリアをクリックで選択、Ctrl/⌘+クリックで複数選択。ドラッグで移動(複数選択時はまとめて移動し、スナップは選択全体の外接矩形で判定)、角・辺のハンドルでリサイズできます。操作値はすべて整数で記録されます。", "scale": "実解像度 {{realWidth}}×{{realHeight}} / プレビュー {{previewWidth}}×{{previewHeight}}", "snapHint": "Shift でスナップ無効" }, diff --git a/src/store/useWallStore.ts b/src/store/useWallStore.ts index abe8898..a65cb1f 100644 --- a/src/store/useWallStore.ts +++ b/src/store/useWallStore.ts @@ -103,6 +103,8 @@ export interface WallStoreState { addPreparing: (area?: VisibleArea) => void; removePreparing: (index: number) => void; updatePreparing: (index: number, patch: Partial) => void; + /** 複数エリアの位置を 1 回の set() でまとめて更新する(複数選択ドラッグ用)。 */ + moveAreas: (moves: AreaMove[]) => void; // --- background --- addBackgroundLayer: (layer: BackgroundLayer) => void; @@ -136,6 +138,16 @@ export interface WallStoreState { // ヘルパ // --------------------------------------------------------------------------- +/** + * `moveAreas` の 1 件分。プレビューでの複数選択ドラッグが、選択中の全エリアを + * 1 回の set() でまとめて動かすために使う(エリアごとに setMain/setLocked/… を + * 呼ぶと 1 pointermove あたり N 回の再レンダリング+永続化が走ってしまう)。 + */ +export type AreaMove = + | { kind: 'main'; x: number; y: number } + | { kind: 'locked'; x: number; y: number } + | { kind: 'preparing'; index: number; x: number; y: number }; + /** * 座標 patch を Area に当てて正規化する。 * 整数化・非有限値の排除・最小サイズ 1・rows/columns >= 1 はすべて `floorArea`(coords.ts)が担保する。 @@ -317,6 +329,37 @@ export const useWallStore = create()( }, })), + moveAreas: (moves) => + set((s) => { + let main = s.wall.layout.main; + let locked = s.wall.layout.locked; + let preparing: VisibleArea[] | null = null; + for (const m of moves) { + if (m.kind === 'main') { + main = mergeAreaPatch(main, { x: m.x, y: m.y }); + } else if (m.kind === 'locked') { + locked = mergeAreaPatch(locked, { x: m.x, y: m.y }); + } else { + preparing ??= [...s.wall.layout.preparing]; + const cur = preparing[m.index]; + if (cur) { + preparing[m.index] = mergeAreaPatch(cur, { x: m.x, y: m.y }); + } + } + } + return { + wall: { + ...s.wall, + layout: { + ...s.wall.layout, + main, + locked, + preparing: preparing ?? s.wall.layout.preparing, + }, + }, + }; + }), + addBackgroundLayer: (layer) => set((s) => ({ wall: { From 33384b252a6dfb483cf844bfa67ca08e16dccb60 Mon Sep 17 00:00:00 2001 From: bafv4 Date: Fri, 28 Aug 2026 23:04:56 +0900 Subject: [PATCH 2/5] =?UTF-8?q?feat(update):=20=E8=B5=B7=E5=8B=95=E6=99=82?= =?UTF-8?q?=E3=81=AE=E6=9B=B4=E6=96=B0=E9=80=9A=E7=9F=A5=E3=81=A8=E3=83=AA?= =?UTF-8?q?=E3=83=AA=E3=83=BC=E3=82=B9=E3=82=A2=E3=82=BB=E3=83=83=E3=83=88?= =?UTF-8?q?=E3=81=AE=E3=83=80=E3=82=A6=E3=83=B3=E3=83=AD=E3=83=BC=E3=83=89?= =?UTF-8?q?=E8=A3=9C=E5=8A=A9=E3=82=92=E8=BF=BD=E5=8A=A0=EF=BC=88Desktop?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 起動時に GitHub Releases の最新版を 1 回チェックし、新しいバージョンがあれば タブ上部の横断バナーとトーストで通知する。「ダウンロード」で配布形態に合う アセットを**実行ファイルと同じフォルダ**へ保存する(進捗 % 表示付き)。 自動適用はしない(CLAUDE.md「自動更新なし」の範囲内。適用はユーザー操作)。 - 配布形態を自動判定してアセットを選ぶ。Windows は uninstall.exe の有無 (NSIS)と Program Files 配下か(MSI)で判定し、**それ以外はポータブル exe**。 ポータブルはリネームされ得るためファイル名に依存せず、判別不能時も ポータブルに倒す。macOS は /Applications 配下なら dmg、それ以外は ポータブル zip。アセット命名は release.yml の実物(v3.2.0)で照合。 - ダウンロードは Rust 側(reqwest / rustls)でストリーミング。`.part` に 書いてから rename するので中断で壊れたファイルが残らない。実行フォルダが 書き込み不可(Program Files 等)なら OS のダウンロードフォルダへ フォールバック。macOS は .app の中に埋もれないよう .app を含むフォルダへ。 - URL は Rust 側で自リポジトリの releases/download/ 接頭辞のみ許可 (webview からの任意ダウンロードへの悪用防止)。ファイル名も検証。 - チェック失敗(オフライン・レート制限)は console.warn のみで起動を妨げない。 プレリリースタグは semver 3 要素に解釈できないため通知対象外。 - リリースページのリンクは opener 経由(Tauri webview の target=_blank は 環境依存で無反応のため)。権限追加は不要(opener:default が openUrl / revealItemInDir を含むことをプラグイン実物で確認)。 - 既知の制限: Tauri 実機での表示・ダウンロードの確認は未実施(この環境では tauri dev を起動できない)。package.json のバージョンを一時的に下げて 起動すると通知〜ダウンロードまで再現できる。 - 検証: cargo check / cargo test(semver 比較・アセット選択の 2 件)通過。 tsc / eslint 0 エラー、vite build 成功。Web バンドルへの漏れなし (desktopUpdate は遅延チャンク、index チャンクに tauri/opener 参照 0)。 Web 実機でバナー非表示・GitHub への fetch 0・コンソールエラー 0 を確認。 Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 2 +- REWRITE_SPEC.md | 2 +- src-tauri/Cargo.lock | 295 ++++++++++++++++++- src-tauri/Cargo.toml | 3 + src-tauri/src/lib.rs | 379 +++++++++++++++++++++++++ src/App.tsx | 11 + src/adapters/desktopUpdate.ts | 72 +++++ src/adapters/index.ts | 54 ++++ src/components/DesktopUpdateNotice.tsx | 168 +++++++++++ src/i18n/locales/en.json | 12 + src/i18n/locales/ja.json | 12 + 11 files changed, 1006 insertions(+), 4 deletions(-) create mode 100644 src/adapters/desktopUpdate.ts create mode 100644 src/components/DesktopUpdateNotice.tsx diff --git a/CLAUDE.md b/CLAUDE.md index f74e656..c164cab 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -130,7 +130,7 @@ export async function getPackWriter(): Promise { - 出力先は**ユーザに選ばせて `tauri-plugin-store` に記憶する**のが基本動作。`.minecraft` の自動検出はしない (MCSR は MultiMC/Prism のインスタンス別フォルダが多い)。初回ダイアログの既定パスはホームディレクトリ。 - 権限は `src-tauri/capabilities/` で最小化(`requireLiteralLeadingDot: false` で `.minecraft` を扱う)。 -- 配布対象は Win+Mac、自動更新なし、署名最小限(未署名警告は許容)。 +- 配布対象は Win+Mac、自動更新なし(起動時の更新通知+配布形態に合うリリースアセットを実行ファイルと同じフォルダへダウンロードする補助のみ。適用はユーザー操作)、署名最小限(未署名警告は許容)。 ## Verification diff --git a/REWRITE_SPEC.md b/REWRITE_SPEC.md index 1159856..90f875e 100644 --- a/REWRITE_SPEC.md +++ b/REWRITE_SPEC.md @@ -519,7 +519,7 @@ interface Area { - **状態**:Zustand 5 + `persist`(2層永続化、第7.2章)。 - **デスクトップ**:Tauri 2(Rust シェル、`dialog`/`fs`/`store`/`opener` プラグイン)。 - **配布**:対象は **Windows + macOS**。両 OS の実機でビルドするため CI は必須としない。 - **自動更新なし**(`tauri-plugin-updater` は導入しない)。**署名は最小限**。 + **自動更新なし(更新通知+リリースアセットのダウンロード補助のみ。`update_check`/`update_download`)**(`tauri-plugin-updater` は導入しない)。**署名は最小限**。 → macOS は notarization 無しで Gatekeeper 警告(初回は右クリック→開く)、Windows は未署名で SmartScreen 警告が出る点は許容する。 - **ZIP**:JSZip(Web アダプタ)。 - **音声**:ffmpeg.wasm を両環境で共有。**ffmpeg-core を自前バンドル/ホスト**して unpkg 依存を排除。 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 782d225..5b90356 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -459,6 +459,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", +] + [[package]] name = "chrono" version = "0.4.45" @@ -549,6 +560,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" @@ -1241,8 +1261,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -1264,10 +1286,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", "wasip2", "wasip3", + "wasm-bindgen", ] [[package]] @@ -1532,6 +1557,22 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + [[package]] name = "hyper-util" version = "0.1.20" @@ -1970,6 +2011,12 @@ version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "markup5ever" version = "0.38.0" @@ -2666,6 +2713,62 @@ dependencies = [ "memchr", ] +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +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.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83" +dependencies = [ + "bytes", + "getrandom 0.4.2", + "lru-slab", + "rand", + "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.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + [[package]] name = "quote" version = "1.0.45" @@ -2687,6 +2790,32 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core", +] + +[[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", +] + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -2762,6 +2891,44 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + [[package]] name = "reqwest" version = "0.13.4" @@ -2820,6 +2987,20 @@ dependencies = [ "windows-sys 0.60.2", ] +[[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" @@ -2848,12 +3029,53 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" 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" @@ -3044,6 +3266,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" @@ -3114,7 +3348,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -3252,6 +3486,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" @@ -3413,7 +3653,7 @@ dependencies = [ "percent-encoding", "plist", "raw-window-handle", - "reqwest", + "reqwest 0.13.4", "serde", "serde_json", "serde_repr", @@ -3855,6 +4095,16 @@ dependencies = [ "syn 2.0.117", ] +[[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" @@ -4174,6 +4424,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" @@ -4269,6 +4525,7 @@ dependencies = [ name = "wall-maker-v3" version = "3.2.0" dependencies = [ + "reqwest 0.12.28", "serde", "serde_json", "tauri", @@ -4424,6 +4681,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" @@ -4480,6 +4747,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webview2-com" version = "0.38.2" @@ -4710,6 +4986,15 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -5245,6 +5530,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + [[package]] name = "zerotrie" version = "0.2.4" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 95faf5d..6862ee5 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -30,4 +30,7 @@ tauri-plugin-store = "2" tauri-plugin-os = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" +# 更新チェック(GitHub Releases API)とリリースアセットのダウンロード用。 +# rustls-tls: OS の OpenSSL に依存しない。default-features 無効で不要機能を落とす。 +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 25578fc..e01034d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -10,6 +10,9 @@ // * `read_pack_zip` — 任意の .zip パスを丸ごと生バイトで返す // * `read_pack_folder` — 任意のフォルダを再帰 walk し、相対パス付きの生バイトで返す // * `path_is_dir` — 指定パスが実在するフォルダかを返す(記憶した保存先の存在検証用) +// * `update_check` / `update_download` +// — GitHub Releases の最新版チェックと、配布形態に合うアセットの +// 「実行ファイルと同じフォルダ」へのダウンロード(自動適用はしない) // * `binary_put` / `binary_get` / `binary_delete` / `binary_keys` // — 永続化バイナリ(画像/音声)の実体ストア。appDataDir/binaries/ に置く // (フロントの `store/storage/desktop.ts` が使う。CLAUDE.md 第7.2章) @@ -450,6 +453,380 @@ fn binary_keys(app: tauri::AppHandle) -> Result, String> { Ok(out) } +// --------------------------------------------------------------------------- +// 更新通知+リリースアセットのダウンロード +// --------------------------------------------------------------------------- +// +// 方針(CLAUDE.md「自動更新なし」の範囲内): +// * インストールの自動適用はしない。GitHub Releases の最新版をチェックして通知し、 +// ユーザーが押したときだけ、**現在の実行ファイルと同じフォルダ**へ該当アセットを +// ダウンロードする(適用=インストーラ実行や差し替えはユーザー自身が行う)。 +// * 配布形態に合わせたアセットを選ぶ。ポータブル版で使用中ならポータブル版を、 +// インストーラ導入なら NSIS setup(無ければ MSI)を選ぶ。判定はアンインストーラの +// 有無と実行ファイルの場所で行い、**判別できないときはポータブル版に倒す** +// (ポータブル exe はリネームされ得るため、ファイル名には依存しない)。 +// * ダウンロード先が書き込み不可(Program Files 等)の場合は OS のダウンロード +// フォルダへフォールバックし、実際の保存先を返す。 + +/// 更新チェック対象のリポジトリ(このアプリ自身)。 +const UPDATE_REPO_API: &str = + "https://api.github.com/repos/bafv4/wall-maker-v3/releases/latest"; +/// ダウンロードを許可する URL の接頭辞。これ以外への fetch はコマンド側で拒否する +/// (webview から任意 URL のダウンロードに悪用されないための最終防衛線)。 +const UPDATE_DOWNLOAD_PREFIX: &str = + "https://github.com/bafv4/wall-maker-v3/releases/download/"; + +#[derive(Deserialize)] +struct GhReleaseAsset { + name: String, + browser_download_url: String, + size: u64, +} + +#[derive(Deserialize)] +struct GhRelease { + tag_name: String, + html_url: String, + assets: Vec, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct UpdateInfo { + /// 最新版のバージョン(先頭 v なし。例 "3.3.0") + version: String, + /// リリースページ URL + release_url: String, + /// この配布形態に合うアセット。見つからなければ None(リリースページへ誘導) + asset_name: Option, + asset_url: Option, + asset_size: Option, +} + +#[derive(Serialize, Clone)] +#[serde(rename_all = "camelCase")] +struct DownloadProgress { + received: u64, + /// Content-Length が無いと None + total: Option, +} + +/// "v3.2.0" / "3.2.0" → (3,2,0)。3 要素の数値でなければ None(プレリリース等は比較しない)。 +fn parse_semver_triple(s: &str) -> Option<(u64, u64, u64)> { + let s = s.strip_prefix('v').unwrap_or(s); + let mut it = s.split('.'); + let major = it.next()?.parse().ok()?; + let minor = it.next()?.parse().ok()?; + let patch = it.next()?.parse().ok()?; + if it.next().is_some() { + return None; + } + Some((major, minor, patch)) +} + +fn is_newer_version(latest_tag: &str, current: &str) -> bool { + match (parse_semver_triple(latest_tag), parse_semver_triple(current)) { + (Some(l), Some(c)) => l > c, + _ => false, + } +} + +/// この実行環境の配布形態。 +#[derive(PartialEq, Debug, Clone, Copy)] +enum InstallKind { + /// ポータブル(単一 exe / 展開した .app)。判別不能時の既定でもある。 + Portable, + /// インストーラ導入(Windows: NSIS/MSI、macOS: /Applications 配下) + Installed, +} + +#[allow(unreachable_code)] +fn detect_install_kind(exe: &Path) -> InstallKind { + #[cfg(target_os = "windows")] + { + if let Some(dir) = exe.parent() { + // Tauri の NSIS インストーラは同じフォルダに uninstall.exe を置く + if dir.join("uninstall.exe").is_file() { + return InstallKind::Installed; + } + } + let p = exe.to_string_lossy().to_ascii_lowercase(); + if p.contains("\\program files") { + return InstallKind::Installed; // MSI(per-machine) + } + return InstallKind::Portable; + } + #[cfg(target_os = "macos")] + { + let p = exe.to_string_lossy(); + if p.starts_with("/Applications/") { + return InstallKind::Installed; + } + return InstallKind::Portable; + } + let _ = exe; + InstallKind::Portable +} + +/// 配布形態に合うアセットをリリースのアセット一覧から選ぶ。 +/// アセット命名は release.yml が生成する実物に合わせる(v3.2.0 で確認): +/// Wall.Maker_3.2.0_x64-setup.exe / Wall.Maker_3.2.0_x64_en-US.msi / +/// WallMaker-v3.2.0-portable-windows-x64.exe / +/// Wall.Maker_3.2.0_universal.dmg / WallMaker-v3.2.0-portable-macos-universal.zip +#[allow(unreachable_code, unused_variables)] +fn pick_asset<'a>( + assets: &'a [GhReleaseAsset], + kind: InstallKind, +) -> Option<&'a GhReleaseAsset> { + let find = |pred: &dyn Fn(&str) -> bool| { + assets.iter().find(|a| pred(&a.name.to_ascii_lowercase())) + }; + #[cfg(target_os = "windows")] + { + return match kind { + InstallKind::Portable => find(&|n| n.contains("portable-windows")), + InstallKind::Installed => find(&|n| n.ends_with("-setup.exe")) + .or_else(|| find(&|n| n.ends_with(".msi"))), + }; + } + #[cfg(target_os = "macos")] + { + return match kind { + InstallKind::Portable => find(&|n| n.contains("portable-macos")), + InstallKind::Installed => find(&|n| n.ends_with(".dmg")), + }; + } + None +} + +/// ダウンロード先フォルダ = 実行ファイルと同じフォルダ。 +/// macOS の .app バンドル内(Contents/MacOS)で動いている場合は、ファイルが +/// バンドルの中に埋もれないよう .app を**含む**フォルダまで上がる。 +fn update_dest_dir() -> Result { + let exe = std::env::current_exe().map_err(|e| format!("実行パス取得に失敗: {e}"))?; + #[allow(unused_mut)] + let mut dir = exe + .parent() + .ok_or("実行ファイルの親フォルダがありません")? + .to_path_buf(); + #[cfg(target_os = "macos")] + { + let mut cur = dir.clone(); + loop { + if cur.extension().map(|e| e == "app").unwrap_or(false) { + if let Some(p) = cur.parent() { + dir = p.to_path_buf(); + } + break; + } + match cur.parent() { + Some(p) => cur = p.to_path_buf(), + None => break, + } + } + } + Ok(dir) +} + +fn github_client() -> Result { + reqwest::Client::builder() + // GitHub API は User-Agent 必須(無いと 403) + .user_agent(concat!("wall-maker-v3/", env!("CARGO_PKG_VERSION"))) + .timeout(std::time::Duration::from_secs(60 * 10)) + .connect_timeout(std::time::Duration::from_secs(15)) + .build() + .map_err(|e| format!("HTTP クライアント初期化に失敗: {e}")) +} + +/// 最新リリースを調べ、現在より新しければ配布形態に合うアセット情報を返す。 +/// 新しくなければ Ok(None)。ネットワーク失敗はエラー(フロントは黙って握る)。 +#[tauri::command] +async fn update_check(current_version: String) -> Result, String> { + let client = github_client()?; + let release: GhRelease = client + .get(UPDATE_REPO_API) + .header("Accept", "application/vnd.github+json") + .send() + .await + .map_err(|e| format!("更新チェックのリクエストに失敗: {e}"))? + .error_for_status() + .map_err(|e| format!("更新チェックが HTTP エラー: {e}"))? + .json() + .await + .map_err(|e| format!("更新チェックの応答を解釈できません: {e}"))?; + + if !is_newer_version(&release.tag_name, ¤t_version) { + return Ok(None); + } + let exe = std::env::current_exe().map_err(|e| format!("実行パス取得に失敗: {e}"))?; + let asset = pick_asset(&release.assets, detect_install_kind(&exe)); + Ok(Some(UpdateInfo { + version: release + .tag_name + .strip_prefix('v') + .unwrap_or(&release.tag_name) + .to_string(), + release_url: release.html_url, + asset_name: asset.map(|a| a.name.clone()), + asset_url: asset.map(|a| a.browser_download_url.clone()), + asset_size: asset.map(|a| a.size), + })) +} + +/// リリースアセットを実行ファイルと同じフォルダへダウンロードする。 +/// 書き込めない場合(Program Files 等)は OS のダウンロードフォルダへ。 +/// `.part` に書いてから rename するので、中断で壊れたファイルが残らない。 +/// 戻り値は保存した絶対パス。 +#[tauri::command] +async fn update_download( + app: tauri::AppHandle, + url: String, + file_name: String, + on_progress: tauri::ipc::Channel, +) -> Result { + if !url.starts_with(UPDATE_DOWNLOAD_PREFIX) { + return Err("このリポジトリのリリース以外からはダウンロードできません".into()); + } + if file_name.is_empty() + || file_name + .chars() + .any(|c| c == '/' || c == '\\' || c == ':') + || file_name.contains("..") + { + return Err(format!("不正なファイル名: {file_name}")); + } + + // 保存先: 実行ファイルと同じフォルダ → 書けなければ OS のダウンロードフォルダ + let mut dir = update_dest_dir()?; + let probe = dir.join(format!(".{file_name}.wm-probe")); + let writable = fs::write(&probe, b"").is_ok(); + let _ = fs::remove_file(&probe); + if !writable { + dir = app + .path() + .download_dir() + .map_err(|e| format!("ダウンロードフォルダの取得に失敗: {e}"))?; + } + + let client = github_client()?; + let mut resp = client + .get(&url) + .send() + .await + .map_err(|e| format!("ダウンロード開始に失敗: {e}"))? + .error_for_status() + .map_err(|e| format!("ダウンロードが HTTP エラー: {e}"))?; + + let total = resp.content_length(); + let part = dir.join(format!("{file_name}.part")); + let final_path = dir.join(&file_name); + + // 同期 I/O をこの async 内で直接使う(1 ダウンロードのみで並列性は不要。 + // tokio::fs を足すより std::fs で十分)。 + let mut file = fs::File::create(&part) + .map_err(|e| format!("一時ファイル作成に失敗 {}: {e}", part.display()))?; + let mut received: u64 = 0; + let mut last_emit: u64 = 0; + use std::io::Write; + loop { + let chunk = resp.chunk().await.map_err(|e| { + let _ = fs::remove_file(&part); + format!("ダウンロード中に切断されました: {e}") + })?; + let Some(bytes) = chunk else { break }; + file.write_all(&bytes).map_err(|e| { + let _ = fs::remove_file(&part); + format!("書き込みに失敗 {}: {e}", part.display()) + })?; + received += bytes.len() as u64; + // 進捗はチャンクごとに送ると IPC が煩すぎるので 512KB ごと + if received - last_emit >= 512 * 1024 { + last_emit = received; + let _ = on_progress.send(DownloadProgress { received, total }); + } + } + file.sync_all().ok(); + drop(file); + let _ = on_progress.send(DownloadProgress { received, total }); + + // 同名ファイルがあれば上書き(同じバージョンの再ダウンロード) + if final_path.exists() { + fs::remove_file(&final_path) + .map_err(|e| format!("既存ファイルの置換に失敗 {}: {e}", final_path.display()))?; + } + fs::rename(&part, &final_path) + .map_err(|e| format!("ファイルの確定に失敗 {}: {e}", final_path.display()))?; + Ok(final_path.to_string_lossy().to_string()) +} + +#[cfg(test)] +mod update_tests { + use super::*; + + #[test] + fn semver_compare() { + assert!(is_newer_version("v3.3.0", "3.2.0")); + assert!(is_newer_version("3.10.0", "3.9.9")); + assert!(!is_newer_version("v3.2.0", "3.2.0")); + assert!(!is_newer_version("v3.1.9", "3.2.0")); + // プレリリースや不正な形式は「新しくない」扱い(通知しない) + assert!(!is_newer_version("v3.3.0-beta.1", "3.2.0")); + assert!(!is_newer_version("garbage", "3.2.0")); + assert!(!is_newer_version("v3.3.0", "garbage")); + } + + fn asset(name: &str) -> GhReleaseAsset { + GhReleaseAsset { + name: name.into(), + browser_download_url: format!("{UPDATE_DOWNLOAD_PREFIX}v9.9.9/{name}"), + size: 1, + } + } + + #[test] + fn asset_pick_matches_distribution() { + let assets = vec![ + asset("Wall.Maker_3.3.0_universal.dmg"), + asset("Wall.Maker_3.3.0_x64-setup.exe"), + asset("Wall.Maker_3.3.0_x64_en-US.msi"), + asset("WallMaker-v3.3.0-portable-macos-universal.zip"), + asset("WallMaker-v3.3.0-portable-windows-x64.exe"), + ]; + #[cfg(target_os = "windows")] + { + assert_eq!( + pick_asset(&assets, InstallKind::Portable).unwrap().name, + "WallMaker-v3.3.0-portable-windows-x64.exe" + ); + assert_eq!( + pick_asset(&assets, InstallKind::Installed).unwrap().name, + "Wall.Maker_3.3.0_x64-setup.exe" + ); + // NSIS setup が無いリリースでは MSI に倒れる + let no_nsis: Vec<_> = assets + .iter() + .filter(|a| !a.name.ends_with("-setup.exe")) + .map(|a| asset(&a.name)) + .collect(); + assert_eq!( + pick_asset(&no_nsis, InstallKind::Installed).unwrap().name, + "Wall.Maker_3.3.0_x64_en-US.msi" + ); + } + #[cfg(target_os = "macos")] + { + assert_eq!( + pick_asset(&assets, InstallKind::Portable).unwrap().name, + "WallMaker-v3.3.0-portable-macos-universal.zip" + ); + assert_eq!( + pick_asset(&assets, InstallKind::Installed).unwrap().name, + "Wall.Maker_3.3.0_universal.dmg" + ); + } + } +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() @@ -480,6 +857,8 @@ pub fn run() { binary_get, binary_delete, binary_keys, + update_check, + update_download, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src/App.tsx b/src/App.tsx index e31f29a..fc856ee 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -61,6 +61,12 @@ const SoundsEditor = lazy(() => default: m.SoundsEditor, })), ); +// 更新通知バナー(Desktop のみ描画。Web の初期チャンクに入れない) +const DesktopUpdateNotice = lazy(() => + import('./components/DesktopUpdateNotice').then((m) => ({ + default: m.DesktopUpdateNotice, + })), +); /** * タブ本文のチャンク取得中に出すプレースホルダ。 @@ -106,6 +112,11 @@ function App() {
{!desktop && } + {desktop && ( + + + + )}
{ + return invoke('update_check', { currentVersion }); +} + +/** + * アセットをダウンロードして保存した絶対パスを返す。 + * 進捗は 512KB ごとに `onProgress` へ届く(最後に必ず 1 回)。 + */ +export async function downloadAppUpdateDesktop( + url: string, + fileName: string, + onProgress: (p: AppUpdateProgress) => void, +): Promise { + const channel = new Channel(); + channel.onmessage = onProgress; + return invoke('update_download', { + url, + fileName, + onProgress: channel, + }); +} + +/** 保存したファイルを OS のファイルマネージャで表示する(失敗は呼び出し側で握る)。 */ +export async function revealDownloadedFile(path: string): Promise { + const { revealItemInDir } = await import('@tauri-apps/plugin-opener'); + await revealItemInDir(path); +} + +/** + * URL を OS 既定ブラウザで開く。Tauri webview 内の `` は + * 環境によって何も起きないため、外部リンクは必ずこちらを使う。 + */ +export async function openExternalUrlDesktop(url: string): Promise { + const { openUrl } = await import('@tauri-apps/plugin-opener'); + await openUrl(url); +} diff --git a/src/adapters/index.ts b/src/adapters/index.ts index 1ca4f90..733fc8b 100644 --- a/src/adapters/index.ts +++ b/src/adapters/index.ts @@ -16,6 +16,9 @@ import type { PackReadResult, PackReadSource } from '../core/types'; // 型のみの import。`import type` は実行時に消えるため、Web バンドルへ // `desktopSaveTarget`(= `@tauri-apps/*`)が引き込まれることはない。 import type { SaveTargetRestore } from './desktopSaveTarget'; +import type { AppUpdateInfo, AppUpdateProgress } from './desktopUpdate'; + +export type { AppUpdateInfo, AppUpdateProgress } from './desktopUpdate'; export type { PackReadResult, PackReadSource } from '../core/types'; @@ -86,3 +89,54 @@ export async function forgetSaveTarget(): Promise { const { clearSaveTarget } = await import('./desktopSaveTarget'); await clearSaveTarget(); } + +// --------------------------------------------------------------------------- +// 更新通知(Desktop のみ。Web はリロードで常に最新なので no-op) +// --------------------------------------------------------------------------- + +/** + * 最新リリースをチェックし、現在より新しければ情報を返す。Web では常に null。 + * ネットワーク失敗は throw する(呼び出し側は通知を出さず console に留めること)。 + */ +export async function checkAppUpdate( + currentVersion: string, +): Promise { + if (!isTauri()) return null; + const { checkAppUpdateDesktop } = await import('./desktopUpdate'); + return checkAppUpdateDesktop(currentVersion); +} + +/** + * リリースアセットを実行ファイルと同じフォルダへダウンロードし、保存パスを返す。 + * Desktop 専用(Web から呼ぶと reject)。 + */ +export async function downloadAppUpdate( + url: string, + fileName: string, + onProgress: (p: AppUpdateProgress) => void, +): Promise { + if (!isTauri()) throw new Error('downloadAppUpdate は Desktop 専用です'); + const { downloadAppUpdateDesktop } = await import('./desktopUpdate'); + return downloadAppUpdateDesktop(url, fileName, onProgress); +} + +/** 保存済みファイルを OS のファイルマネージャで表示する(Desktop 専用、失敗は無視してよい)。 */ +export async function revealAppUpdateFile(path: string): Promise { + if (!isTauri()) return; + const { revealDownloadedFile } = await import('./desktopUpdate'); + await revealDownloadedFile(path); +} + +/** + * 外部 URL を既定ブラウザで開く。Desktop は opener プラグイン経由 + * (webview 内アンカーの `target=_blank` は環境依存で無反応になるため)、 + * Web は通常の新規タブ。 + */ +export async function openExternalUrl(url: string): Promise { + if (isTauri()) { + const { openExternalUrlDesktop } = await import('./desktopUpdate'); + await openExternalUrlDesktop(url); + return; + } + window.open(url, '_blank', 'noopener,noreferrer'); +} diff --git a/src/components/DesktopUpdateNotice.tsx b/src/components/DesktopUpdateNotice.tsx new file mode 100644 index 0000000..f5e5a9c --- /dev/null +++ b/src/components/DesktopUpdateNotice.tsx @@ -0,0 +1,168 @@ +/** + * DesktopUpdateNotice — 起動時の更新通知バナー(Desktop 専用)。 + * + * 起動時に一度だけ GitHub Releases の最新版をチェックし、新しいバージョンが + * あればタブの上に横断バナーを出す。「ダウンロード」で配布形態に合うアセットを + * **実行ファイルと同じフォルダ**へ保存する(適用はユーザー自身。自動更新はしない)。 + * + * - チェック結果・却下状態はモジュールレベルに持つ。タブ切替やアンマウントで + * 再フェッチ・再通知しないため(チェックはアプリ起動につき 1 回)。 + * - チェック失敗は console.warn のみ(オフライン起動を邪魔しない)。 + * - 対応アセットが見つからないリリース(命名変更など)はダウンロードボタンを + * 出さず、リリースページへのリンクだけ出す。 + */ + +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + checkAppUpdate, + downloadAppUpdate, + openExternalUrl, + revealAppUpdateFile, + type AppUpdateInfo, +} from '../adapters'; +import { errMsg } from '../core/errors'; +import { Button, toast } from './ui'; + +// アプリ起動につき 1 回だけチェックする(コンポーネントの再マウントでは再実行しない) +let checkPromise: Promise | null = null; +let dismissedVersion: string | null = null; +let toastShown = false; + +function ensureCheck(current: string): Promise { + checkPromise ??= checkAppUpdate(current).catch((e: unknown) => { + // オフラインや API レート制限で普通に失敗しうる。起動を妨げない。 + console.warn('update check failed', e); + return null; + }); + return checkPromise; +} + +type DownloadState = + | { kind: 'idle' } + | { kind: 'downloading'; percent: number | null } + | { kind: 'done'; path: string }; + +export function DesktopUpdateNotice() { + const { t } = useTranslation(); + const [info, setInfo] = useState(null); + const [download, setDownload] = useState({ kind: 'idle' }); + const [dismissed, setDismissed] = useState(false); + + useEffect(() => { + let canceled = false; + void ensureCheck(__APP_VERSION__).then((result) => { + if (canceled || !result) return; + setInfo(result); + if (result.version === dismissedVersion) setDismissed(true); + if (!toastShown) { + toastShown = true; + toast.info(t('update.toastAvailable', { version: result.version })); + } + }); + return () => { + canceled = true; + }; + }, [t]); + + if (!info || dismissed) return null; + + const startDownload = () => { + if (!info.assetUrl || !info.assetName) return; + setDownload({ kind: 'downloading', percent: null }); + downloadAppUpdate(info.assetUrl, info.assetName, (p) => { + setDownload({ + kind: 'downloading', + percent: + p.total && p.total > 0 + ? Math.min(100, Math.round((p.received / p.total) * 100)) + : null, + }); + }) + .then((path) => { + setDownload({ kind: 'done', path }); + toast.success(t('update.downloaded', { path })); + }) + .catch((e: unknown) => { + setDownload({ kind: 'idle' }); + toast.error(t('update.downloadFailed', { error: errMsg(e) })); + }); + }; + + const dismiss = () => { + dismissedVersion = info.version; + setDismissed(true); + }; + + const sizeMb = + info.assetSize != null + ? ` (${(info.assetSize / 1024 / 1024).toFixed(1)} MB)` + : ''; + + return ( +
+ + {t('update.available', { + version: info.version, + current: __APP_VERSION__, + })} + + + {download.kind === 'idle' && info.assetUrl && info.assetName && ( + + )} + {download.kind === 'downloading' && ( + + {download.percent === null + ? t('update.downloading') + : t('update.downloadingPercent', { percent: download.percent })} + + )} + {download.kind === 'done' && ( + <> + + {t('update.savedTo', { path: download.path })} + + + + )} + + {/* Tauri webview では target=_blank が環境依存で無反応のため opener 経由で開く */} + { + e.preventDefault(); + void openExternalUrl(info.releaseUrl).catch((err: unknown) => { + console.warn('open release page failed', err); + }); + }} + className="text-blue-600 underline hover:text-blue-800" + > + {t('update.releasePage')} + + + +
+ ); +} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 28f293d..73f0aa6 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -11,6 +11,18 @@ "lock": "Lock", "sound": "Sound" }, + "update": { + "toastAvailable": "A new version v{{version}} is available", + "available": "A new version v{{version}} is available (current v{{current}})", + "download": "Download {{name}}", + "downloading": "Downloading…", + "downloadingPercent": "Downloading… {{percent}}%", + "savedTo": "Saved to {{path}}", + "downloaded": "Update file saved: {{path}} (run or replace the saved file to apply)", + "downloadFailed": "Failed to download the update: {{error}}", + "showInFolder": "Show in folder", + "releasePage": "Release page" + }, "preview": { "title": "Preview", "hint": "Click an area to select; Ctrl/⌘+click for multi-select. Drag to move (a multi-selection moves together, snapping against the selection's bounding box); use corner / edge handles to resize. All values are stored as integers.", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 62b57bc..b2ae4cc 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -11,6 +11,18 @@ "lock": "Lock", "sound": "サウンド" }, + "update": { + "toastAvailable": "新しいバージョン v{{version}} が公開されています", + "available": "新しいバージョン v{{version}} が公開されています(現在 v{{current}})", + "download": "{{name}} をダウンロード", + "downloading": "ダウンロード中…", + "downloadingPercent": "ダウンロード中… {{percent}}%", + "savedTo": "{{path}} に保存しました", + "downloaded": "更新ファイルを保存しました: {{path}}(適用するには保存したファイルを実行/差し替えてください)", + "downloadFailed": "更新のダウンロードに失敗しました: {{error}}", + "showInFolder": "フォルダで表示", + "releasePage": "リリースページ" + }, "preview": { "title": "プレビュー", "hint": "エリアをクリックで選択、Ctrl/⌘+クリックで複数選択。ドラッグで移動(複数選択時はまとめて移動し、スナップは選択全体の外接矩形で判定)、角・辺のハンドルでリサイズできます。操作値はすべて整数で記録されます。", From 9ebfaf8e78ed262e6193d3bb9ba0601f7be3fd68 Mon Sep 17 00:00:00 2001 From: bafv4 Date: Fri, 28 Aug 2026 23:21:50 +0900 Subject: [PATCH 3/5] =?UTF-8?q?refactor(preview):=20=E3=82=A8=E3=83=AA?= =?UTF-8?q?=E3=82=A2=E9=81=B8=E6=8A=9E=E3=82=92=20store=20=E3=81=B8?= =?UTF-8?q?=E7=A7=BB=E7=AE=A1=E3=81=97=E3=80=81=E8=A4=87=E6=95=B0=E9=81=B8?= =?UTF-8?q?=E6=8A=9E=E3=81=BE=E3=82=8F=E3=82=8A=E3=82=92=E7=B0=A1=E7=B4=A0?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /simplify レビュー(4 アングル)の指摘の適用(プレビュー側)。 - エリア選択を WallPreview のローカル state から store(ui.selectedAreas)へ移す。 preparing の選択は index 参照のため、削除時に「配列が縮んだら preparing の選択を 全部落とす」という応急処置を入れていたが、removePreparing が削除と同じ set() 内で index を詰め直す形に変えた(removeBackgroundLayer が selectedBackgroundLayerId を 直すのと同じ流儀)。{#1, #3} 選択中に #1 を消しても #3 の選択が正しく追跡される。 - AreaTarget を store の公用語として export し、AreaMove = AreaTarget & {x,y} に。 WallPreview 側の AreaRef は AreaTarget | layer の合成になり、moveAreas への 変換に使っていた再タグ付け switch(到達不能な layer 分岐込み)が消えた。 - selectedRefs の identity を安定化(何も落ちなければ入力をそのまま返す)。 ドラッグ中は layout.preparing の identity が毎フレーム変わるため、従来は 選択が不変でも Set の再構築とハンドラの再生成が毎イベント走っていた。 - initDragDerived を items 配列を直接受ける形に、二重ネストの if を一本化、 クリック縮退ガードの冗長条件(pressedRef が含意する mode/件数)を削除。 - 検証: tsc / eslint 0 エラー。dev サーバで store を直接呼び、removePreparing の 選択詰め直し({#0,#2} 選択で #0 削除 → 旧 #2 を index 1 として追跡)、 空選択の DEFAULT_AREA_SELECTION への縮退、reset 時の初期化を確認。 Co-Authored-By: Claude Fable 5 --- src/components/WallPreview.tsx | 164 ++++++++++++++++----------------- src/store/useWallStore.ts | 77 +++++++++++++--- 2 files changed, 141 insertions(+), 100 deletions(-) diff --git a/src/components/WallPreview.tsx b/src/components/WallPreview.tsx index 9b5eb4a..e27a92c 100644 --- a/src/components/WallPreview.tsx +++ b/src/components/WallPreview.tsx @@ -46,7 +46,12 @@ import type { VisibleArea, WallState, } from '../core/state'; -import { useWallStore, type AreaMove } from '../store/useWallStore'; +import { + DEFAULT_AREA_SELECTION, + useWallStore, + type AreaMove, + type AreaTarget, +} from '../store/useWallStore'; import { snapMove, snapResize, type Handle } from './snap'; import { cn } from './ui/cn'; @@ -55,9 +60,7 @@ import { cn } from './ui/cn'; // --------------------------------------------------------------------------- type AreaRef = - | { kind: 'main' } - | { kind: 'locked' } - | { kind: 'preparing'; index: number } + | AreaTarget | { kind: 'layer'; layerId: string }; type Mode = { type: 'move' } | { type: 'resize'; handle: Handle }; @@ -82,6 +85,11 @@ const LOCKED_REF: AreaRef = { kind: 'locked' }; const MAIN_KEY = areaRefKey(MAIN_REF); const LOCKED_KEY = areaRefKey(LOCKED_REF); +/** 選択集合から layer を除いて AreaTarget[] に絞る(選択にはエリアしか入らない)。 */ +function areaTargets(refs: readonly AreaRef[]): AreaTarget[] { + return refs.filter((r): r is AreaTarget => r.kind !== 'layer'); +} + /** * 複数選択ドラッグの開始閾値(CSS px)。この距離を超えるまでは store に反映しない。 * 超えずに離した場合は「クリック」とみなし、押したメンバーの単独選択へ縮退する @@ -545,39 +553,28 @@ export function WallPreview() { const [preview, setPreview] = useState({ width: 0, height: 0 }); // 選択集合(1 件以上)。Ctrl/⌘+クリックでトグル、通常クリックで単独選択。 - const [rawSelected, setRawSelected] = useState([MAIN_REF]); - // 非表示化・削除で消えたエリアを選択から除く。空になったら main に戻す + // 実体は store(ui.selectedAreas)。preparing 削除時の index 詰め直しは + // removePreparing が削除と同じ set() 内で行う(store 参照)。 + const rawSelected = useWallStore((s) => s.ui.selectedAreas); + const selectAreas = useWallStore((s) => s.selectAreas); + // 非表示のエリアを選択から除く。空になったら main に戻す // (既存挙動: 空クリックでも main が選択される=常に何かが選択されている)。 - const selectedRefs = useMemo(() => { + // ドラッグ中は layout.preparing の identity が毎フレーム変わるため、 + // 何も落ちなかったときは入力の identity をそのまま返して下流の memo を守る。 + const selectedRefs = useMemo(() => { const alive = rawSelected.filter((r) => { if (r.kind === 'main') return true; if (r.kind === 'locked') return layout.locked.show; - if (r.kind === 'preparing') - return layout.preparing[r.index]?.show === true; - return false; // layer は複数選択の対象外 + return layout.preparing[r.index]?.show === true; }); - return alive.length > 0 ? alive : [MAIN_REF]; + if (alive.length === rawSelected.length) return rawSelected; + return alive.length > 0 ? alive : DEFAULT_AREA_SELECTION; }, [rawSelected, layout.locked.show, layout.preparing]); const selectedKeys = useMemo( () => new Set(selectedRefs.map(areaRefKey)), [selectedRefs], ); - // preparing の選択参照は index ベースなので、配列が縮むと別エリアを指してしまう - // (#1 を消すと旧 #2 が index 0 になり、選択が黙って付け替わる)。エリアに安定 ID が - // 無い以上ここでは追跡できないため、削除を検知したら preparing の選択を全て落とす。 - const prevPreparingLen = useRef(layout.preparing.length); - useEffect(() => { - const prev = prevPreparingLen.current; - prevPreparingLen.current = layout.preparing.length; - if (layout.preparing.length < prev) { - setRawSelected((sel) => { - const kept = sel.filter((r) => r.kind !== 'preparing'); - return kept.length === sel.length ? sel : kept; - }); - } - }, [layout.preparing.length]); - /** ドラッグ中のオーバーレイ(px 表示・スナップ ヒット線)。null = 操作なし。 */ const [dragOverlay, setDragOverlay] = useState<{ cell: AreaCell; @@ -687,11 +684,10 @@ export function WallPreview() { * 再計算して drag に書き込む。開始時と startCellRefreshed の読み直し後に呼ぶ。 */ const initDragDerived = useCallback( - (drag: Pick): Pick< - DragState, - 'bbox0' | 'maxStartX' | 'maxStartY' | 'cand' - > => { - const cells = drag.items.map((i) => i.startCell); + ( + items: DragItem[], + ): Pick => { + const cells = items.map((i) => i.startCell); const bbox0 = boundingBox(cells); let maxStartX = -Infinity; let maxStartY = -Infinity; @@ -703,9 +699,7 @@ export function WallPreview() { bbox0, maxStartX, maxStartY, - cand: buildSnapCandidates( - new Set(drag.items.map((i) => areaRefKey(i.ref))), - ), + cand: buildSnapCandidates(new Set(items.map((i) => areaRefKey(i.ref)))), }; }, [buildSnapCandidates], @@ -715,7 +709,7 @@ export function WallPreview() { const startDrag = useCallback( ( e: ReactPointerEvent, - refs: AreaRef[], + refs: readonly AreaRef[], mode: Mode, pressedRef: AreaRef | null = null, ) => { @@ -731,7 +725,7 @@ export function WallPreview() { items, pressedRef, moved: false, - ...initDragDerived({ items, mode }), + ...initDragDerived(items), startClientX: e.clientX, startClientY: e.clientY, startCellRefreshed: false, @@ -759,7 +753,7 @@ export function WallPreview() { ) => { if (mode.type === 'resize') { // ハンドルは単独選択のときしか表示されないが、防御的に単独化しておく - setRawSelected([refId]); + if (refId.kind !== 'layer') selectAreas([refId]); startDrag(e, [refId], mode); return; } @@ -769,11 +763,15 @@ export function WallPreview() { // Shift は「ドラッグ中のスナップ無効」に割当済みなのでトグルには使わない。 // Shift+pointerdown をトグルにすると、ヒントに書かれた // 「Shift を押しながらドラッグ=スナップなし移動」が開始できなくなる。 - setRawSelected( - selectedKeys.has(key) - ? selectedRefs.filter((r) => areaRefKey(r) !== key) - : [...selectedRefs, refId], - ); + if (refId.kind !== 'layer') { + selectAreas( + selectedKeys.has(key) + ? areaTargets(selectedRefs).filter( + (r) => areaRefKey(r) !== key, + ) + : [...areaTargets(selectedRefs), refId], + ); + } return; } if (selectedKeys.has(key)) { @@ -781,11 +779,11 @@ export function WallPreview() { // 閾値を超えず離された「クリック」なら pressedRef の単独選択へ縮退する。 startDrag(e, selectedRefs, mode, refId); } else { - setRawSelected([refId]); + if (refId.kind !== 'layer') selectAreas([refId]); startDrag(e, [refId], mode); } }, - [selectedKeys, selectedRefs, startDrag], + [selectedKeys, selectedRefs, selectAreas, startDrag], ); const onPointerDownLayer = useCallback( @@ -817,7 +815,7 @@ export function WallPreview() { setDragOverlay(null); return; } - Object.assign(drag, initDragDerived(drag)); + Object.assign(drag, initDragDerived(drag.items)); } const dxCss = e.clientX - drag.startClientX; const dyCss = e.clientY - drag.startClientY; @@ -861,15 +859,15 @@ export function WallPreview() { // 開始セルは整数(store が floor 済み)なので bbox0 も整数。移動量を // 「floor した外接矩形の変位」として整数化してから各エリアへ同量だけ足すことで、 // グループ内の相対位置が 1px も崩れない剛体移動になる。 - if (drag.items.length > 1 && !drag.moved) { - // 複数選択では閾値を超えるまで store に反映しない。超えずに離されたら - // 「クリック」として onPointerUp が pressedRef の単独選択へ縮退させる。 - if ( - Math.abs(dxCss) < GROUP_DRAG_START_THRESHOLD_CSS && - Math.abs(dyCss) < GROUP_DRAG_START_THRESHOLD_CSS - ) { - return; - } + // 複数選択では閾値を超えるまで store に反映しない。超えずに離されたら + // 「クリック」として onPointerUp が pressedRef の単独選択へ縮退させる。 + if ( + drag.items.length > 1 && + !drag.moved && + Math.abs(dxCss) < GROUP_DRAG_START_THRESHOLD_CSS && + Math.abs(dyCss) < GROUP_DRAG_START_THRESHOLD_CSS + ) { + return; } drag.moved = true; @@ -916,31 +914,31 @@ export function WallPreview() { }; if (!rememberDragResult(drag, finalBox, hitX, hitY)) return; - if (drag.items.length === 1) { - // 単独はレイヤも含むので従来どおり dispatchCell(フル cell)で反映 - const item = drag.items[0]; - dispatchCell(item.ref, { - ...item.startCell, - x: item.startCell.x + intDx, - y: item.startCell.y + intDy, + // layer は選択に入らないので単独ドラッグでしか来ない。それ以外は + // 件数に関わらず moveAreas(1 回の set())でまとめて反映する。 + const soloLayer = + drag.items.length === 1 && drag.items[0].ref.kind === 'layer' + ? drag.items[0] + : null; + if (soloLayer) { + dispatchCell(soloLayer.ref, { + ...soloLayer.startCell, + x: soloLayer.startCell.x + intDx, + y: soloLayer.startCell.y + intDy, }); } else { - // グループは 1 回の set() でまとめて反映(AreaMove へ変換。layer は来ない) moveAreas( - drag.items.flatMap((item): AreaMove[] => { - const x = item.startCell.x + intDx; - const y = item.startCell.y + intDy; - switch (item.ref.kind) { - case 'main': - return [{ kind: 'main', x, y }]; - case 'locked': - return [{ kind: 'locked', x, y }]; - case 'preparing': - return [{ kind: 'preparing', index: item.ref.index, x, y }]; - case 'layer': - return []; - } - }), + drag.items.flatMap((item): AreaMove[] => + item.ref.kind === 'layer' + ? [] + : [ + { + ...item.ref, + x: item.startCell.x + intDx, + y: item.startCell.y + intDy, + }, + ], + ), ); } setDragOverlay({ cell: finalBox, hitX, hitY }); @@ -954,20 +952,16 @@ export function WallPreview() { // 複数選択のメンバーを「クリック」(閾値未満で離した)→ そのエリアの単独選択へ。 // 旧実装の「pointerdown したエリアが必ず単独選択になる」を、グループ移動と // 両立する形(move が始まらなかったときだけ)で復元する。 - if ( - drag.mode.type === 'move' && - drag.items.length > 1 && - !drag.moved && - drag.pressedRef - ) { - setRawSelected([drag.pressedRef]); + // pressedRef は複数選択の move でしか渡されないので、mode/件数の再検査は不要 + if (drag.pressedRef && !drag.moved && drag.pressedRef.kind !== 'layer') { + selectAreas([drag.pressedRef]); } dragRef.current = null; setDragOverlay(null); if (e.currentTarget.hasPointerCapture(e.pointerId)) { e.currentTarget.releasePointerCapture(e.pointerId); } - }, []); + }, [selectAreas]); // ---- 描画用補助 ---- @@ -1008,7 +1002,7 @@ export function WallPreview() { // 空クリックで main の単独選択に戻す(Ctrl/⌘ 中はトグル操作の // 空振りとして選択を維持する。Shift はスナップ無効用なので対象外) if (e.target === e.currentTarget && !e.ctrlKey && !e.metaKey) { - setRawSelected([MAIN_REF]); + selectAreas(DEFAULT_AREA_SELECTION); } }} > diff --git a/src/store/useWallStore.ts b/src/store/useWallStore.ts index a65cb1f..82fa056 100644 --- a/src/store/useWallStore.ts +++ b/src/store/useWallStore.ts @@ -70,10 +70,15 @@ export type ExtraTextureSlot = /** * UI state — 永続化対象外(partialize で除外)。 - * 現状は背景レイヤの選択 ID のみ。後続 Phase でツール選択・モーダル状態などを追加可能。 */ export interface UIState { selectedBackgroundLayerId: string | null; + /** + * プレビューのエリア選択(1 件以上)。preparing は index 参照なので、 + * `removePreparing` が削除と同じ set() 内で index を詰め直す + * (`removeBackgroundLayer` が selectedBackgroundLayerId を直すのと同じ流儀)。 + */ + selectedAreas: readonly AreaTarget[]; } export interface WallStoreState { @@ -86,6 +91,7 @@ export interface WallStoreState { // --- UI --- selectBackgroundLayer: (id: string | null) => void; + selectAreas: (targets: readonly AreaTarget[]) => void; // --- 解像度 / 全体スケール --- setResolution: (r: Resolution) => void; @@ -138,15 +144,25 @@ export interface WallStoreState { // ヘルパ // --------------------------------------------------------------------------- +/** + * レイアウト上のエリアの参照。プレビューの選択(`ui.selectedAreas`)と + * `moveAreas` が共有する語彙(preparing は配列 index で参照する。store・ + * LayoutEditor・AreaEditor が index 前提のため。安定 ID 化は将来課題)。 + */ +export type AreaTarget = + | { kind: 'main' } + | { kind: 'locked' } + | { kind: 'preparing'; index: number }; + /** * `moveAreas` の 1 件分。プレビューでの複数選択ドラッグが、選択中の全エリアを * 1 回の set() でまとめて動かすために使う(エリアごとに setMain/setLocked/… を * 呼ぶと 1 pointermove あたり N 回の再レンダリング+永続化が走ってしまう)。 */ -export type AreaMove = - | { kind: 'main'; x: number; y: number } - | { kind: 'locked'; x: number; y: number } - | { kind: 'preparing'; index: number; x: number; y: number }; +export type AreaMove = AreaTarget & { x: number; y: number }; + +/** エリア選択の既定値(main の単独選択)。識別子を共有して identity 比較を安定させる。 */ +export const DEFAULT_AREA_SELECTION: readonly AreaTarget[] = [{ kind: 'main' }]; /** * 座標 patch を Area に当てて正規化する。 @@ -210,12 +226,15 @@ export const useWallStore = create()( persist( (set) => ({ wall: createDefaultWallState(), - ui: { selectedBackgroundLayerId: null }, + ui: { selectedBackgroundLayerId: null, selectedAreas: DEFAULT_AREA_SELECTION }, reset: () => set({ wall: createDefaultWallState(), - ui: { selectedBackgroundLayerId: null }, + ui: { + selectedBackgroundLayerId: null, + selectedAreas: DEFAULT_AREA_SELECTION, + }, }), // import など外部由来の state も幾何の不変条件へ引き戻してから採用する。 @@ -224,6 +243,14 @@ export const useWallStore = create()( selectBackgroundLayer: (id) => set((s) => ({ ui: { ...s.ui, selectedBackgroundLayerId: id } })), + selectAreas: (targets) => + set((s) => ({ + ui: { + ...s.ui, + selectedAreas: targets.length > 0 ? targets : DEFAULT_AREA_SELECTION, + }, + })), + // 非有限値("1e999" → Infinity)が入ると sx/sy が Infinity になり layout 全体が壊れる。 // toSafeInt で 1..MAX_DIMENSION の整数に落としてからスケールする。 setResolution: (r) => @@ -306,15 +333,35 @@ export const useWallStore = create()( })), removePreparing: (index) => - set((s) => ({ - wall: { - ...s.wall, - layout: { - ...s.wall.layout, - preparing: s.wall.layout.preparing.filter((_, i) => i !== index), + set((s) => { + // 選択は index 参照なので、削除された番号を落とし、後続の番号を詰める。 + // これを削除と同じ set() でやらないと、選択が黙って別のエリアを指す。 + const selectedAreas = s.ui.selectedAreas.flatMap( + (t): AreaTarget[] => { + if (t.kind !== 'preparing') return [t]; + if (t.index === index) return []; + return t.index > index ? [{ ...t, index: t.index - 1 }] : [t]; }, - }, - })), + ); + return { + wall: { + ...s.wall, + layout: { + ...s.wall.layout, + preparing: s.wall.layout.preparing.filter( + (_, i) => i !== index, + ), + }, + }, + ui: { + ...s.ui, + selectedAreas: + selectedAreas.length > 0 + ? selectedAreas + : DEFAULT_AREA_SELECTION, + }, + }; + }), updatePreparing: (index, patch) => set((s) => ({ From d816881699d4b3c12ba9e725397280479890cfbf Mon Sep 17 00:00:00 2001 From: bafv4 Date: Fri, 28 Aug 2026 23:22:11 +0900 Subject: [PATCH 4/5] =?UTF-8?q?refactor(update):=20=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E6=A9=9F=E8=83=BD=E3=82=92=20update.rs=20=E3=81=B8=E5=88=86?= =?UTF-8?q?=E9=9B=A2=E3=81=97=E3=80=81=E5=A4=96=E9=83=A8=E3=83=AA=E3=83=B3?= =?UTF-8?q?=E3=82=AF=E3=82=92=20ExternalLink=20=E3=81=AB=E5=85=B1=E6=9C=89?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /simplify レビュー(4 アングル)の指摘の適用(更新機能側)。 - Rust の更新機能一式(約 380 行、lib.rs の 44%)を src-tauri/src/update.rs へ分離。 lib.rs は自身のヘッダが定義する「ファイル I/O コマンドの最小表面積」に戻る。 併せて #[allow(unreachable_code)] 等 3 つのサプレッションを撤去し、 detect_install_kind / pick_asset / update_dest_dir を OS ごとの cfg 付き関数定義に分割。 - 効率: reqwest::Client を OnceLock で共有(TLS 設定とプールを check/download で再利用)、 ダウンロードを 1MB BufWriter に(8〜16KB チャンク直書きだと 100MB 級で数千 syscall)、 sync_all を削除(.part → rename で完全性は担保済み。再取得可能な成果物に fsync は 不要で、async ワーカーを数百 ms〜数秒ブロックしていた)、書き込み可否プローブを .part 作成自体に統合。 - ExternalLink を ui/ に昇格して adapter の openExternalUrl 経由に。Tauri webview では a[target=_blank] が環境依存で無反応になるため、バナーだけでなく AboutModal の 8 リンク(Desktop でも表示される)が確実に開くようになる。 - バナーの更新トーストを削除(直下に出るバナーと同文の二重通知)。付随する toastShown / dismissedVersion グローバルと update.toastAvailable キー(ja/en)も削除。 サイズ表示は新設の core/format.ts formatBytes に統一。 - release.yml にアセット命名が update.rs の pick_asset と結合している旨を注記 (リネームすると更新通知がアセットを見つけられなくなる契約を両端に明記)。 - 検証: cargo check / cargo test 2 件 / tsc / eslint(error 0)/ vite build 通過。 Web バンドルへの漏れなし(index チャンクに opener/update 参照 0)。Web 実機で AboutModal のリンクが openExternalUrl 経由で開くこと、コンソールエラー 0 を確認。 Tauri 実機での動作確認は引き続き未実施。 Co-Authored-By: Claude Fable 5 --- .github/workflows/release.yml | 4 + src-tauri/src/lib.rs | 382 +---------------------- src-tauri/src/update.rs | 405 +++++++++++++++++++++++++ src/adapters/desktopUpdate.ts | 3 +- src/components/AboutModal.tsx | 21 +- src/components/DesktopUpdateNotice.tsx | 50 +-- src/components/ui/ExternalLink.tsx | 37 +++ src/components/ui/index.ts | 1 + src/core/format.ts | 12 + src/i18n/locales/en.json | 1 - src/i18n/locales/ja.json | 1 - 11 files changed, 478 insertions(+), 439 deletions(-) create mode 100644 src-tauri/src/update.rs create mode 100644 src/components/ui/ExternalLink.tsx create mode 100644 src/core/format.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c2421df..892152a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -105,6 +105,10 @@ jobs: # Win: tauri/cargo が出す単一 .exe(フロント資産は exe に埋め込み済み)。 # WebView2 は同梱せず OS 既定ランタイムを使う。 # Mac: .app バンドル自体がポータブル。zip して配布する。 + # 注意: ポータブル版のファイル名(WallMaker-*-portable-windows-x64.exe / + # WallMaker-*-portable-macos-universal.zip)と NSIS/MSI/dmg の命名は + # `src-tauri/src/update.rs` の `pick_asset` が照合している。名前を変えると + # アプリ内の更新通知がアセットを見つけられなくなる(リリースページ誘導のみになる)。 - name: Stage portable (Windows — standalone .exe) if: matrix.platform == 'windows-latest' shell: bash diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e01034d..77fdaeb 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -10,7 +10,7 @@ // * `read_pack_zip` — 任意の .zip パスを丸ごと生バイトで返す // * `read_pack_folder` — 任意のフォルダを再帰 walk し、相対パス付きの生バイトで返す // * `path_is_dir` — 指定パスが実在するフォルダかを返す(記憶した保存先の存在検証用) -// * `update_check` / `update_download` +// * `update_check` / `update_download`(実装は update.rs) // — GitHub Releases の最新版チェックと、配布形態に合うアセットの // 「実行ファイルと同じフォルダ」へのダウンロード(自動適用はしない) // * `binary_put` / `binary_get` / `binary_delete` / `binary_keys` @@ -44,6 +44,8 @@ use serde::{Deserialize, Serialize}; use tauri::ipc::{InvokeBody, Request, Response}; use tauri::Manager; +mod update; + // --------------------------------------------------------------------------- // 生バイト IPC コンテナ // --------------------------------------------------------------------------- @@ -453,380 +455,6 @@ fn binary_keys(app: tauri::AppHandle) -> Result, String> { Ok(out) } -// --------------------------------------------------------------------------- -// 更新通知+リリースアセットのダウンロード -// --------------------------------------------------------------------------- -// -// 方針(CLAUDE.md「自動更新なし」の範囲内): -// * インストールの自動適用はしない。GitHub Releases の最新版をチェックして通知し、 -// ユーザーが押したときだけ、**現在の実行ファイルと同じフォルダ**へ該当アセットを -// ダウンロードする(適用=インストーラ実行や差し替えはユーザー自身が行う)。 -// * 配布形態に合わせたアセットを選ぶ。ポータブル版で使用中ならポータブル版を、 -// インストーラ導入なら NSIS setup(無ければ MSI)を選ぶ。判定はアンインストーラの -// 有無と実行ファイルの場所で行い、**判別できないときはポータブル版に倒す** -// (ポータブル exe はリネームされ得るため、ファイル名には依存しない)。 -// * ダウンロード先が書き込み不可(Program Files 等)の場合は OS のダウンロード -// フォルダへフォールバックし、実際の保存先を返す。 - -/// 更新チェック対象のリポジトリ(このアプリ自身)。 -const UPDATE_REPO_API: &str = - "https://api.github.com/repos/bafv4/wall-maker-v3/releases/latest"; -/// ダウンロードを許可する URL の接頭辞。これ以外への fetch はコマンド側で拒否する -/// (webview から任意 URL のダウンロードに悪用されないための最終防衛線)。 -const UPDATE_DOWNLOAD_PREFIX: &str = - "https://github.com/bafv4/wall-maker-v3/releases/download/"; - -#[derive(Deserialize)] -struct GhReleaseAsset { - name: String, - browser_download_url: String, - size: u64, -} - -#[derive(Deserialize)] -struct GhRelease { - tag_name: String, - html_url: String, - assets: Vec, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct UpdateInfo { - /// 最新版のバージョン(先頭 v なし。例 "3.3.0") - version: String, - /// リリースページ URL - release_url: String, - /// この配布形態に合うアセット。見つからなければ None(リリースページへ誘導) - asset_name: Option, - asset_url: Option, - asset_size: Option, -} - -#[derive(Serialize, Clone)] -#[serde(rename_all = "camelCase")] -struct DownloadProgress { - received: u64, - /// Content-Length が無いと None - total: Option, -} - -/// "v3.2.0" / "3.2.0" → (3,2,0)。3 要素の数値でなければ None(プレリリース等は比較しない)。 -fn parse_semver_triple(s: &str) -> Option<(u64, u64, u64)> { - let s = s.strip_prefix('v').unwrap_or(s); - let mut it = s.split('.'); - let major = it.next()?.parse().ok()?; - let minor = it.next()?.parse().ok()?; - let patch = it.next()?.parse().ok()?; - if it.next().is_some() { - return None; - } - Some((major, minor, patch)) -} - -fn is_newer_version(latest_tag: &str, current: &str) -> bool { - match (parse_semver_triple(latest_tag), parse_semver_triple(current)) { - (Some(l), Some(c)) => l > c, - _ => false, - } -} - -/// この実行環境の配布形態。 -#[derive(PartialEq, Debug, Clone, Copy)] -enum InstallKind { - /// ポータブル(単一 exe / 展開した .app)。判別不能時の既定でもある。 - Portable, - /// インストーラ導入(Windows: NSIS/MSI、macOS: /Applications 配下) - Installed, -} - -#[allow(unreachable_code)] -fn detect_install_kind(exe: &Path) -> InstallKind { - #[cfg(target_os = "windows")] - { - if let Some(dir) = exe.parent() { - // Tauri の NSIS インストーラは同じフォルダに uninstall.exe を置く - if dir.join("uninstall.exe").is_file() { - return InstallKind::Installed; - } - } - let p = exe.to_string_lossy().to_ascii_lowercase(); - if p.contains("\\program files") { - return InstallKind::Installed; // MSI(per-machine) - } - return InstallKind::Portable; - } - #[cfg(target_os = "macos")] - { - let p = exe.to_string_lossy(); - if p.starts_with("/Applications/") { - return InstallKind::Installed; - } - return InstallKind::Portable; - } - let _ = exe; - InstallKind::Portable -} - -/// 配布形態に合うアセットをリリースのアセット一覧から選ぶ。 -/// アセット命名は release.yml が生成する実物に合わせる(v3.2.0 で確認): -/// Wall.Maker_3.2.0_x64-setup.exe / Wall.Maker_3.2.0_x64_en-US.msi / -/// WallMaker-v3.2.0-portable-windows-x64.exe / -/// Wall.Maker_3.2.0_universal.dmg / WallMaker-v3.2.0-portable-macos-universal.zip -#[allow(unreachable_code, unused_variables)] -fn pick_asset<'a>( - assets: &'a [GhReleaseAsset], - kind: InstallKind, -) -> Option<&'a GhReleaseAsset> { - let find = |pred: &dyn Fn(&str) -> bool| { - assets.iter().find(|a| pred(&a.name.to_ascii_lowercase())) - }; - #[cfg(target_os = "windows")] - { - return match kind { - InstallKind::Portable => find(&|n| n.contains("portable-windows")), - InstallKind::Installed => find(&|n| n.ends_with("-setup.exe")) - .or_else(|| find(&|n| n.ends_with(".msi"))), - }; - } - #[cfg(target_os = "macos")] - { - return match kind { - InstallKind::Portable => find(&|n| n.contains("portable-macos")), - InstallKind::Installed => find(&|n| n.ends_with(".dmg")), - }; - } - None -} - -/// ダウンロード先フォルダ = 実行ファイルと同じフォルダ。 -/// macOS の .app バンドル内(Contents/MacOS)で動いている場合は、ファイルが -/// バンドルの中に埋もれないよう .app を**含む**フォルダまで上がる。 -fn update_dest_dir() -> Result { - let exe = std::env::current_exe().map_err(|e| format!("実行パス取得に失敗: {e}"))?; - #[allow(unused_mut)] - let mut dir = exe - .parent() - .ok_or("実行ファイルの親フォルダがありません")? - .to_path_buf(); - #[cfg(target_os = "macos")] - { - let mut cur = dir.clone(); - loop { - if cur.extension().map(|e| e == "app").unwrap_or(false) { - if let Some(p) = cur.parent() { - dir = p.to_path_buf(); - } - break; - } - match cur.parent() { - Some(p) => cur = p.to_path_buf(), - None => break, - } - } - } - Ok(dir) -} - -fn github_client() -> Result { - reqwest::Client::builder() - // GitHub API は User-Agent 必須(無いと 403) - .user_agent(concat!("wall-maker-v3/", env!("CARGO_PKG_VERSION"))) - .timeout(std::time::Duration::from_secs(60 * 10)) - .connect_timeout(std::time::Duration::from_secs(15)) - .build() - .map_err(|e| format!("HTTP クライアント初期化に失敗: {e}")) -} - -/// 最新リリースを調べ、現在より新しければ配布形態に合うアセット情報を返す。 -/// 新しくなければ Ok(None)。ネットワーク失敗はエラー(フロントは黙って握る)。 -#[tauri::command] -async fn update_check(current_version: String) -> Result, String> { - let client = github_client()?; - let release: GhRelease = client - .get(UPDATE_REPO_API) - .header("Accept", "application/vnd.github+json") - .send() - .await - .map_err(|e| format!("更新チェックのリクエストに失敗: {e}"))? - .error_for_status() - .map_err(|e| format!("更新チェックが HTTP エラー: {e}"))? - .json() - .await - .map_err(|e| format!("更新チェックの応答を解釈できません: {e}"))?; - - if !is_newer_version(&release.tag_name, ¤t_version) { - return Ok(None); - } - let exe = std::env::current_exe().map_err(|e| format!("実行パス取得に失敗: {e}"))?; - let asset = pick_asset(&release.assets, detect_install_kind(&exe)); - Ok(Some(UpdateInfo { - version: release - .tag_name - .strip_prefix('v') - .unwrap_or(&release.tag_name) - .to_string(), - release_url: release.html_url, - asset_name: asset.map(|a| a.name.clone()), - asset_url: asset.map(|a| a.browser_download_url.clone()), - asset_size: asset.map(|a| a.size), - })) -} - -/// リリースアセットを実行ファイルと同じフォルダへダウンロードする。 -/// 書き込めない場合(Program Files 等)は OS のダウンロードフォルダへ。 -/// `.part` に書いてから rename するので、中断で壊れたファイルが残らない。 -/// 戻り値は保存した絶対パス。 -#[tauri::command] -async fn update_download( - app: tauri::AppHandle, - url: String, - file_name: String, - on_progress: tauri::ipc::Channel, -) -> Result { - if !url.starts_with(UPDATE_DOWNLOAD_PREFIX) { - return Err("このリポジトリのリリース以外からはダウンロードできません".into()); - } - if file_name.is_empty() - || file_name - .chars() - .any(|c| c == '/' || c == '\\' || c == ':') - || file_name.contains("..") - { - return Err(format!("不正なファイル名: {file_name}")); - } - - // 保存先: 実行ファイルと同じフォルダ → 書けなければ OS のダウンロードフォルダ - let mut dir = update_dest_dir()?; - let probe = dir.join(format!(".{file_name}.wm-probe")); - let writable = fs::write(&probe, b"").is_ok(); - let _ = fs::remove_file(&probe); - if !writable { - dir = app - .path() - .download_dir() - .map_err(|e| format!("ダウンロードフォルダの取得に失敗: {e}"))?; - } - - let client = github_client()?; - let mut resp = client - .get(&url) - .send() - .await - .map_err(|e| format!("ダウンロード開始に失敗: {e}"))? - .error_for_status() - .map_err(|e| format!("ダウンロードが HTTP エラー: {e}"))?; - - let total = resp.content_length(); - let part = dir.join(format!("{file_name}.part")); - let final_path = dir.join(&file_name); - - // 同期 I/O をこの async 内で直接使う(1 ダウンロードのみで並列性は不要。 - // tokio::fs を足すより std::fs で十分)。 - let mut file = fs::File::create(&part) - .map_err(|e| format!("一時ファイル作成に失敗 {}: {e}", part.display()))?; - let mut received: u64 = 0; - let mut last_emit: u64 = 0; - use std::io::Write; - loop { - let chunk = resp.chunk().await.map_err(|e| { - let _ = fs::remove_file(&part); - format!("ダウンロード中に切断されました: {e}") - })?; - let Some(bytes) = chunk else { break }; - file.write_all(&bytes).map_err(|e| { - let _ = fs::remove_file(&part); - format!("書き込みに失敗 {}: {e}", part.display()) - })?; - received += bytes.len() as u64; - // 進捗はチャンクごとに送ると IPC が煩すぎるので 512KB ごと - if received - last_emit >= 512 * 1024 { - last_emit = received; - let _ = on_progress.send(DownloadProgress { received, total }); - } - } - file.sync_all().ok(); - drop(file); - let _ = on_progress.send(DownloadProgress { received, total }); - - // 同名ファイルがあれば上書き(同じバージョンの再ダウンロード) - if final_path.exists() { - fs::remove_file(&final_path) - .map_err(|e| format!("既存ファイルの置換に失敗 {}: {e}", final_path.display()))?; - } - fs::rename(&part, &final_path) - .map_err(|e| format!("ファイルの確定に失敗 {}: {e}", final_path.display()))?; - Ok(final_path.to_string_lossy().to_string()) -} - -#[cfg(test)] -mod update_tests { - use super::*; - - #[test] - fn semver_compare() { - assert!(is_newer_version("v3.3.0", "3.2.0")); - assert!(is_newer_version("3.10.0", "3.9.9")); - assert!(!is_newer_version("v3.2.0", "3.2.0")); - assert!(!is_newer_version("v3.1.9", "3.2.0")); - // プレリリースや不正な形式は「新しくない」扱い(通知しない) - assert!(!is_newer_version("v3.3.0-beta.1", "3.2.0")); - assert!(!is_newer_version("garbage", "3.2.0")); - assert!(!is_newer_version("v3.3.0", "garbage")); - } - - fn asset(name: &str) -> GhReleaseAsset { - GhReleaseAsset { - name: name.into(), - browser_download_url: format!("{UPDATE_DOWNLOAD_PREFIX}v9.9.9/{name}"), - size: 1, - } - } - - #[test] - fn asset_pick_matches_distribution() { - let assets = vec![ - asset("Wall.Maker_3.3.0_universal.dmg"), - asset("Wall.Maker_3.3.0_x64-setup.exe"), - asset("Wall.Maker_3.3.0_x64_en-US.msi"), - asset("WallMaker-v3.3.0-portable-macos-universal.zip"), - asset("WallMaker-v3.3.0-portable-windows-x64.exe"), - ]; - #[cfg(target_os = "windows")] - { - assert_eq!( - pick_asset(&assets, InstallKind::Portable).unwrap().name, - "WallMaker-v3.3.0-portable-windows-x64.exe" - ); - assert_eq!( - pick_asset(&assets, InstallKind::Installed).unwrap().name, - "Wall.Maker_3.3.0_x64-setup.exe" - ); - // NSIS setup が無いリリースでは MSI に倒れる - let no_nsis: Vec<_> = assets - .iter() - .filter(|a| !a.name.ends_with("-setup.exe")) - .map(|a| asset(&a.name)) - .collect(); - assert_eq!( - pick_asset(&no_nsis, InstallKind::Installed).unwrap().name, - "Wall.Maker_3.3.0_x64_en-US.msi" - ); - } - #[cfg(target_os = "macos")] - { - assert_eq!( - pick_asset(&assets, InstallKind::Portable).unwrap().name, - "WallMaker-v3.3.0-portable-macos-universal.zip" - ); - assert_eq!( - pick_asset(&assets, InstallKind::Installed).unwrap().name, - "Wall.Maker_3.3.0_universal.dmg" - ); - } - } -} - #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() @@ -857,8 +485,8 @@ pub fn run() { binary_get, binary_delete, binary_keys, - update_check, - update_download, + update::update_check, + update::update_download, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/update.rs b/src-tauri/src/update.rs new file mode 100644 index 0000000..c4b3459 --- /dev/null +++ b/src-tauri/src/update.rs @@ -0,0 +1,405 @@ +// 更新通知+リリースアセットのダウンロード。 +// +// 方針(CLAUDE.md「自動更新なし」の範囲内): +// * インストールの自動適用はしない。GitHub Releases の最新版をチェックして通知し、 +// ユーザーが押したときだけ、**現在の実行ファイルと同じフォルダ**へ該当アセットを +// ダウンロードする(適用=インストーラ実行や差し替えはユーザー自身が行う)。 +// * 配布形態に合わせたアセットを選ぶ。ポータブル版で使用中ならポータブル版を、 +// インストーラ導入なら NSIS setup(無ければ MSI)を選ぶ。判定はアンインストーラの +// 有無と実行ファイルの場所で行い、**判別できないときはポータブル版に倒す** +// (ポータブル exe はリネームされ得るため、ファイル名には依存しない)。 +// * ダウンロード先が書き込み不可(Program Files 等)の場合は OS のダウンロード +// フォルダへフォールバックし、実際の保存先を返す。 + +use std::fs; +use std::io::Write; +use std::path::PathBuf; +use std::sync::OnceLock; + +use serde::{Deserialize, Serialize}; +use tauri::Manager; + +/// 更新チェック対象のリポジトリ(このアプリ自身)。 +const UPDATE_REPO_API: &str = + "https://api.github.com/repos/bafv4/wall-maker-v3/releases/latest"; +/// ダウンロードを許可する URL の接頭辞。これ以外への fetch はコマンド側で拒否する +/// (webview から任意 URL のダウンロードに悪用されないための最終防衛線)。 +const UPDATE_DOWNLOAD_PREFIX: &str = + "https://github.com/bafv4/wall-maker-v3/releases/download/"; + +#[derive(Deserialize)] +struct GhReleaseAsset { + name: String, + browser_download_url: String, + size: u64, +} + +#[derive(Deserialize)] +struct GhRelease { + tag_name: String, + html_url: String, + assets: Vec, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateInfo { + /// 最新版のバージョン(先頭 v なし。例 "3.3.0") + version: String, + /// リリースページ URL + release_url: String, + /// この配布形態に合うアセット。見つからなければ None(リリースページへ誘導) + asset_name: Option, + asset_url: Option, + asset_size: Option, +} + +#[derive(Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct DownloadProgress { + received: u64, + /// Content-Length が無いと None + total: Option, +} + +/// "v3.2.0" / "3.2.0" → (3,2,0)。3 要素の数値でなければ None(プレリリース等は比較しない)。 +fn parse_semver_triple(s: &str) -> Option<(u64, u64, u64)> { + let s = s.strip_prefix('v').unwrap_or(s); + let mut it = s.split('.'); + let major = it.next()?.parse().ok()?; + let minor = it.next()?.parse().ok()?; + let patch = it.next()?.parse().ok()?; + if it.next().is_some() { + return None; + } + Some((major, minor, patch)) +} + +fn is_newer_version(latest_tag: &str, current: &str) -> bool { + match (parse_semver_triple(latest_tag), parse_semver_triple(current)) { + (Some(l), Some(c)) => l > c, + _ => false, + } +} + +/// この実行環境の配布形態。 +#[derive(Clone, Copy)] +enum InstallKind { + /// ポータブル(単一 exe / 展開した .app)。判別不能時の既定でもある。 + Portable, + /// インストーラ導入(Windows: NSIS/MSI、macOS: /Applications 配下) + Installed, +} + +#[cfg(target_os = "windows")] +fn detect_install_kind() -> InstallKind { + let Ok(exe) = std::env::current_exe() else { + return InstallKind::Portable; + }; + // Tauri の NSIS インストーラは同じフォルダに uninstall.exe を置く + if exe + .parent() + .is_some_and(|dir| dir.join("uninstall.exe").is_file()) + { + return InstallKind::Installed; + } + if exe + .to_string_lossy() + .to_ascii_lowercase() + .contains("\\program files") + { + return InstallKind::Installed; // MSI(per-machine) + } + InstallKind::Portable +} + +#[cfg(target_os = "macos")] +fn detect_install_kind() -> InstallKind { + match std::env::current_exe() { + Ok(exe) if exe.to_string_lossy().starts_with("/Applications/") => { + InstallKind::Installed + } + _ => InstallKind::Portable, + } +} + +#[cfg(not(any(target_os = "windows", target_os = "macos")))] +fn detect_install_kind() -> InstallKind { + InstallKind::Portable +} + +fn find_asset( + assets: &[GhReleaseAsset], + pred: impl Fn(&str) -> bool, +) -> Option<&GhReleaseAsset> { + assets.iter().find(|a| pred(&a.name.to_ascii_lowercase())) +} + +/// 配布形態に合うアセットをリリースのアセット一覧から選ぶ。 +/// アセット命名は release.yml が生成する実物に合わせる(v3.2.0 で確認。 +/// release.yml 側にも「この名前を変えると更新通知が壊れる」旨を注記してある): +/// Wall.Maker_3.2.0_x64-setup.exe / Wall.Maker_3.2.0_x64_en-US.msi / +/// WallMaker-v3.2.0-portable-windows-x64.exe / +/// Wall.Maker_3.2.0_universal.dmg / WallMaker-v3.2.0-portable-macos-universal.zip +#[cfg(target_os = "windows")] +fn pick_asset(assets: &[GhReleaseAsset], kind: InstallKind) -> Option<&GhReleaseAsset> { + match kind { + InstallKind::Portable => find_asset(assets, |n| n.contains("portable-windows")), + InstallKind::Installed => find_asset(assets, |n| n.ends_with("-setup.exe")) + .or_else(|| find_asset(assets, |n| n.ends_with(".msi"))), + } +} + +#[cfg(target_os = "macos")] +fn pick_asset(assets: &[GhReleaseAsset], kind: InstallKind) -> Option<&GhReleaseAsset> { + match kind { + InstallKind::Portable => find_asset(assets, |n| n.contains("portable-macos")), + InstallKind::Installed => find_asset(assets, |n| n.ends_with(".dmg")), + } +} + +#[cfg(not(any(target_os = "windows", target_os = "macos")))] +fn pick_asset(_assets: &[GhReleaseAsset], _kind: InstallKind) -> Option<&GhReleaseAsset> { + None +} + +/// ダウンロード先フォルダ = 実行ファイルと同じフォルダ。 +/// macOS の .app バンドル内(Contents/MacOS)で動いている場合は、ファイルが +/// バンドルの中に埋もれないよう .app を**含む**フォルダまで上がる。 +fn update_dest_dir() -> Result { + let exe = std::env::current_exe().map_err(|e| format!("実行パス取得に失敗: {e}"))?; + let dir = exe + .parent() + .ok_or("実行ファイルの親フォルダがありません")? + .to_path_buf(); + #[cfg(target_os = "macos")] + { + let mut cur = dir.clone(); + loop { + if cur.extension().map(|e| e == "app").unwrap_or(false) { + if let Some(p) = cur.parent() { + return Ok(p.to_path_buf()); + } + break; + } + match cur.parent() { + Some(p) => cur = p.to_path_buf(), + None => break, + } + } + } + Ok(dir) +} + +/// 共有 HTTP クライアント。TLS 設定(webpki ルート証明書のパース)とコネクション +/// プールを update_check / update_download で使い回す(Client の clone は Arc の複製)。 +fn github_client() -> Result { + static CLIENT: OnceLock = OnceLock::new(); + if let Some(c) = CLIENT.get() { + return Ok(c.clone()); + } + let built = reqwest::Client::builder() + // GitHub API は User-Agent 必須(無いと 403) + .user_agent(concat!("wall-maker-v3/", env!("CARGO_PKG_VERSION"))) + .timeout(std::time::Duration::from_secs(60 * 10)) + .connect_timeout(std::time::Duration::from_secs(15)) + .build() + .map_err(|e| format!("HTTP クライアント初期化に失敗: {e}"))?; + Ok(CLIENT.get_or_init(|| built).clone()) +} + +/// 最新リリースを調べ、現在より新しければ配布形態に合うアセット情報を返す。 +/// 新しくなければ Ok(None)。ネットワーク失敗はエラー(フロントは黙って握る)。 +#[tauri::command] +pub async fn update_check(current_version: String) -> Result, String> { + let client = github_client()?; + let release: GhRelease = client + .get(UPDATE_REPO_API) + .header("Accept", "application/vnd.github+json") + .send() + .await + .map_err(|e| format!("更新チェックのリクエストに失敗: {e}"))? + .error_for_status() + .map_err(|e| format!("更新チェックが HTTP エラー: {e}"))? + .json() + .await + .map_err(|e| format!("更新チェックの応答を解釈できません: {e}"))?; + + if !is_newer_version(&release.tag_name, ¤t_version) { + return Ok(None); + } + let asset = pick_asset(&release.assets, detect_install_kind()); + Ok(Some(UpdateInfo { + version: release + .tag_name + .strip_prefix('v') + .unwrap_or(&release.tag_name) + .to_string(), + release_url: release.html_url, + asset_name: asset.map(|a| a.name.clone()), + asset_url: asset.map(|a| a.browser_download_url.clone()), + asset_size: asset.map(|a| a.size), + })) +} + +/// リリースアセットを実行ファイルと同じフォルダへダウンロードする。 +/// 書き込めない場合(Program Files 等)は OS のダウンロードフォルダへ。 +/// `.part` に書いてから rename するので、中断で壊れたファイルが残らない。 +/// 戻り値は保存した絶対パス。 +#[tauri::command] +pub async fn update_download( + app: tauri::AppHandle, + url: String, + file_name: String, + on_progress: tauri::ipc::Channel, +) -> Result { + if !url.starts_with(UPDATE_DOWNLOAD_PREFIX) { + return Err("このリポジトリのリリース以外からはダウンロードできません".into()); + } + // アセット名は単一のファイル名として検証する(区切り文字・`..`・Windows のドライブ + // 記号を拒否)。lib.rs の validate_binary_key(UUID 用)や write_pack_folder の + // 相対パス検証(複数セグメント用)とは対象の形が違うため、ここで独立に持つ。 + if file_name.is_empty() + || file_name.chars().any(|c| c == '/' || c == '\\' || c == ':') + || file_name.contains("..") + { + return Err(format!("不正なファイル名: {file_name}")); + } + + let client = github_client()?; + let mut resp = client + .get(&url) + .send() + .await + .map_err(|e| format!("ダウンロード開始に失敗: {e}"))? + .error_for_status() + .map_err(|e| format!("ダウンロードが HTTP エラー: {e}"))?; + let total = resp.content_length(); + + // 保存先: 実行ファイルと同じフォルダ。`.part` の作成自体が書き込み可否の判定を + // 兼ねる(失敗したら OS のダウンロードフォルダで作り直す)。 + let mut dir = update_dest_dir()?; + let mut file = match fs::File::create(dir.join(format!("{file_name}.part"))) { + Ok(f) => f, + Err(_) => { + dir = app + .path() + .download_dir() + .map_err(|e| format!("ダウンロードフォルダの取得に失敗: {e}"))?; + let part = dir.join(format!("{file_name}.part")); + fs::File::create(&part) + .map_err(|e| format!("一時ファイル作成に失敗 {}: {e}", part.display()))? + } + }; + let part = dir.join(format!("{file_name}.part")); + let final_path = dir.join(&file_name); + + // チャンクは TLS レコード単位(8〜16KB)で届くため、素の File だと 100MB 級の + // アセットで数千〜万回の write システムコールになる。1MB バッファでまとめる。 + let mut writer = std::io::BufWriter::with_capacity(1 << 20, &mut file); + let mut received: u64 = 0; + let mut last_emit: u64 = 0; + loop { + let chunk = resp.chunk().await.map_err(|e| { + let _ = fs::remove_file(&part); + format!("ダウンロード中に切断されました: {e}") + })?; + let Some(bytes) = chunk else { break }; + writer.write_all(&bytes).map_err(|e| { + let _ = fs::remove_file(&part); + format!("書き込みに失敗 {}: {e}", part.display()) + })?; + received += bytes.len() as u64; + // 進捗はチャンクごとに送ると IPC が煩すぎるので 512KB ごと + if received - last_emit >= 512 * 1024 { + last_emit = received; + let _ = on_progress.send(DownloadProgress { received, total }); + } + } + writer.flush().map_err(|e| { + let _ = fs::remove_file(&part); + format!("書き込みに失敗 {}: {e}", part.display()) + })?; + drop(writer); + drop(file); + let _ = on_progress.send(DownloadProgress { received, total }); + + // 同名ファイルがあれば上書き(同じバージョンの再ダウンロード)。 + // fsync はしない: 中断は `.part` のままなので壊れたファイルが完成品に見える + // ことはなく、再ダウンロード可能な成果物に耐久性保証は不要。 + if final_path.exists() { + fs::remove_file(&final_path) + .map_err(|e| format!("既存ファイルの置換に失敗 {}: {e}", final_path.display()))?; + } + fs::rename(&part, &final_path) + .map_err(|e| format!("ファイルの確定に失敗 {}: {e}", final_path.display()))?; + Ok(final_path.to_string_lossy().to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn semver_compare() { + assert!(is_newer_version("v3.3.0", "3.2.0")); + assert!(is_newer_version("3.10.0", "3.9.9")); + assert!(!is_newer_version("v3.2.0", "3.2.0")); + assert!(!is_newer_version("v3.1.9", "3.2.0")); + // プレリリースや不正な形式は「新しくない」扱い(通知しない) + assert!(!is_newer_version("v3.3.0-beta.1", "3.2.0")); + assert!(!is_newer_version("garbage", "3.2.0")); + assert!(!is_newer_version("v3.3.0", "garbage")); + } + + fn asset(name: &str) -> GhReleaseAsset { + GhReleaseAsset { + name: name.into(), + browser_download_url: format!("{UPDATE_DOWNLOAD_PREFIX}v9.9.9/{name}"), + size: 1, + } + } + + #[test] + fn asset_pick_matches_distribution() { + let assets = vec![ + asset("Wall.Maker_3.3.0_universal.dmg"), + asset("Wall.Maker_3.3.0_x64-setup.exe"), + asset("Wall.Maker_3.3.0_x64_en-US.msi"), + asset("WallMaker-v3.3.0-portable-macos-universal.zip"), + asset("WallMaker-v3.3.0-portable-windows-x64.exe"), + ]; + #[cfg(target_os = "windows")] + { + assert_eq!( + pick_asset(&assets, InstallKind::Portable).unwrap().name, + "WallMaker-v3.3.0-portable-windows-x64.exe" + ); + assert_eq!( + pick_asset(&assets, InstallKind::Installed).unwrap().name, + "Wall.Maker_3.3.0_x64-setup.exe" + ); + // NSIS setup が無いリリースでは MSI に倒れる + let no_nsis: Vec<_> = assets + .iter() + .filter(|a| !a.name.ends_with("-setup.exe")) + .map(|a| asset(&a.name)) + .collect(); + assert_eq!( + pick_asset(&no_nsis, InstallKind::Installed).unwrap().name, + "Wall.Maker_3.3.0_x64_en-US.msi" + ); + } + #[cfg(target_os = "macos")] + { + assert_eq!( + pick_asset(&assets, InstallKind::Portable).unwrap().name, + "WallMaker-v3.3.0-portable-macos-universal.zip" + ); + assert_eq!( + pick_asset(&assets, InstallKind::Installed).unwrap().name, + "Wall.Maker_3.3.0_universal.dmg" + ); + } + } +} diff --git a/src/adapters/desktopUpdate.ts b/src/adapters/desktopUpdate.ts index a4281e3..5d91d7a 100644 --- a/src/adapters/desktopUpdate.ts +++ b/src/adapters/desktopUpdate.ts @@ -13,6 +13,7 @@ */ import { Channel, invoke } from '@tauri-apps/api/core'; +import { openUrl, revealItemInDir } from '@tauri-apps/plugin-opener'; export interface AppUpdateInfo { /** 最新版のバージョン(先頭 v なし。例 "3.3.0") */ @@ -58,7 +59,6 @@ export async function downloadAppUpdateDesktop( /** 保存したファイルを OS のファイルマネージャで表示する(失敗は呼び出し側で握る)。 */ export async function revealDownloadedFile(path: string): Promise { - const { revealItemInDir } = await import('@tauri-apps/plugin-opener'); await revealItemInDir(path); } @@ -67,6 +67,5 @@ export async function revealDownloadedFile(path: string): Promise { * 環境によって何も起きないため、外部リンクは必ずこちらを使う。 */ export async function openExternalUrlDesktop(url: string): Promise { - const { openUrl } = await import('@tauri-apps/plugin-opener'); await openUrl(url); } diff --git a/src/components/AboutModal.tsx b/src/components/AboutModal.tsx index b44c481..0257227 100644 --- a/src/components/AboutModal.tsx +++ b/src/components/AboutModal.tsx @@ -10,7 +10,7 @@ import { useTranslation, Trans } from 'react-i18next'; import { SUPPORTED_LANGUAGES, setLanguage, type SupportedLanguage } from '../i18n'; -import { Modal } from './ui'; +import { ExternalLink, Modal } from './ui'; export interface AboutModalProps { open: boolean; @@ -24,25 +24,6 @@ const FFMPEG_WASM_REPO = 'https://github.com/ffmpegwasm/ffmpeg.wasm'; const GPL3_URL = 'https://www.gnu.org/licenses/gpl-3.0.html'; const GPL2_URL = 'https://www.gnu.org/licenses/old-licenses/gpl-2.0.html'; -interface LinkProps { - href: string; - // Trans に渡すときは children が空のまま使うため optional。 - children?: React.ReactNode; -} - -function ExternalLink({ href, children }: LinkProps) { - return ( - - {children} - - ); -} - function LanguageSwitcher() { const { t, i18n } = useTranslation(); const current = (i18n.resolvedLanguage ?? diff --git a/src/components/DesktopUpdateNotice.tsx b/src/components/DesktopUpdateNotice.tsx index f5e5a9c..9334b68 100644 --- a/src/components/DesktopUpdateNotice.tsx +++ b/src/components/DesktopUpdateNotice.tsx @@ -5,8 +5,9 @@ * あればタブの上に横断バナーを出す。「ダウンロード」で配布形態に合うアセットを * **実行ファイルと同じフォルダ**へ保存する(適用はユーザー自身。自動更新はしない)。 * - * - チェック結果・却下状態はモジュールレベルに持つ。タブ切替やアンマウントで - * 再フェッチ・再通知しないため(チェックはアプリ起動につき 1 回)。 + * - コンポーネントは App 直下(タブの外)に 1 度だけマウントされ、アンマウント + * されない。checkPromise だけモジュールレベルに置くのは StrictMode の + * 二重 effect と HMR での再フェッチを防ぐため。 * - チェック失敗は console.warn のみ(オフライン起動を邪魔しない)。 * - 対応アセットが見つからないリリース(命名変更など)はダウンロードボタンを * 出さず、リリースページへのリンクだけ出す。 @@ -17,17 +18,15 @@ import { useTranslation } from 'react-i18next'; import { checkAppUpdate, downloadAppUpdate, - openExternalUrl, revealAppUpdateFile, type AppUpdateInfo, } from '../adapters'; import { errMsg } from '../core/errors'; -import { Button, toast } from './ui'; +import { formatBytes } from '../core/format'; +import { Button, ExternalLink, toast } from './ui'; -// アプリ起動につき 1 回だけチェックする(コンポーネントの再マウントでは再実行しない) +// アプリ起動につき 1 回だけチェックする(StrictMode の二重 effect / HMR 対策) let checkPromise: Promise | null = null; -let dismissedVersion: string | null = null; -let toastShown = false; function ensureCheck(current: string): Promise { checkPromise ??= checkAppUpdate(current).catch((e: unknown) => { @@ -52,18 +51,12 @@ export function DesktopUpdateNotice() { useEffect(() => { let canceled = false; void ensureCheck(__APP_VERSION__).then((result) => { - if (canceled || !result) return; - setInfo(result); - if (result.version === dismissedVersion) setDismissed(true); - if (!toastShown) { - toastShown = true; - toast.info(t('update.toastAvailable', { version: result.version })); - } + if (!canceled && result) setInfo(result); }); return () => { canceled = true; }; - }, [t]); + }, []); if (!info || dismissed) return null; @@ -89,15 +82,6 @@ export function DesktopUpdateNotice() { }); }; - const dismiss = () => { - dismissedVersion = info.version; - setDismissed(true); - }; - - const sizeMb = - info.assetSize != null - ? ` (${(info.assetSize / 1024 / 1024).toFixed(1)} MB)` - : ''; return (
@@ -111,7 +95,7 @@ export function DesktopUpdateNotice() { {download.kind === 'idle' && info.assetUrl && info.assetName && ( )} {download.kind === 'downloading' && ( @@ -140,23 +124,13 @@ export function DesktopUpdateNotice() { )} - {/* Tauri webview では target=_blank が環境依存で無反応のため opener 経由で開く */} - { - e.preventDefault(); - void openExternalUrl(info.releaseUrl).catch((err: unknown) => { - console.warn('open release page failed', err); - }); - }} - className="text-blue-600 underline hover:text-blue-800" - > + {t('update.releasePage')} - +