터치로 전략컨트롤 동작

This commit is contained in:
Macbook
2026-06-15 00:19:31 +09:00
parent d8529ed582
commit f35f17df36
9 changed files with 345 additions and 103 deletions
@@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import { import {
handlePalettePointerDown, handlePalettePointerDown,
handlePaletteTouchStart,
needsPointerPaletteDrag, needsPointerPaletteDrag,
shouldSuppressPaletteClick, shouldSuppressPaletteClick,
writePaletteHtmlDragData, writePaletteHtmlDragData,
@@ -55,6 +56,10 @@ export default function PaletteChip({
handlePalettePointerDown(e, buildPayload(), composite ? `${label} + ${label}` : label); handlePalettePointerDown(e, buildPayload(), composite ? `${label} + ${label}` : label);
}; };
const onTouchStart = (e: React.TouchEvent) => {
handlePaletteTouchStart(e, buildPayload(), composite ? `${label} + ${label}` : label);
};
const handleClick = () => { const handleClick = () => {
if (shouldSuppressPaletteClick()) return; if (shouldSuppressPaletteClick()) return;
if (onSelect) onSelect(); if (onSelect) onSelect();
@@ -83,6 +88,7 @@ export default function PaletteChip({
className={`se-palette-card ${color ? `se-palette-card--${color}` : 'se-palette-card--ind'}${composite ? ' se-palette-card--composite' : ''}${selected ? ' se-palette-card--selected' : ''}`} className={`se-palette-card ${color ? `se-palette-card--${color}` : 'se-palette-card--ind'}${composite ? ' se-palette-card--composite' : ''}${selected ? ' se-palette-card--selected' : ''}`}
onDragStart={onDragStart} onDragStart={onDragStart}
onPointerDown={onPointerDown} onPointerDown={onPointerDown}
onTouchStart={onTouchStart}
onClick={handleClick} onClick={handleClick}
onDoubleClick={handleDoubleClick} onDoubleClick={handleDoubleClick}
role="button" role="button"
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { import {
bindPaletteDragOverlayElement, bindPaletteDragOverlayElement,
@@ -6,53 +6,67 @@ import {
notifyPaletteDragEnd, notifyPaletteDragEnd,
notifyPaletteDragMove, notifyPaletteDragMove,
registerPaletteDragOverlay, registerPaletteDragOverlay,
subscribePaletteDrag,
type PaletteDragOverlaySession, type PaletteDragOverlaySession,
} from '../../utils/paletteDragSession'; } from '../../utils/paletteDragSession';
import { clientXYFromReact } from '../../utils/pointerDrag';
/** /**
* Tauri WebView — 드래그 overlay (라벨 위치는 ref 갱신, move 마다 React re-render 없음) * pointer/touch 드래그 overlay (sessionRef 로 stale closure 방지, window touch 백업과 연동)
*/ */
export default function PaletteDragOverlay() { export default function PaletteDragOverlay() {
const [session, setSession] = useState<PaletteDragOverlaySession | null>(null); const [session, setSession] = useState<PaletteDragOverlaySession | null>(null);
const overlayRef = useRef<HTMLDivElement | null>(null); const sessionRef = useRef<PaletteDragOverlaySession | null>(null);
const labelRef = useRef<HTMLDivElement | null>(null); const labelRef = useRef<HTMLDivElement | null>(null);
sessionRef.current = session;
useEffect(() => { useEffect(() => {
registerPaletteDragOverlay({ registerPaletteDragOverlay({
mount: next => { mount: next => {
sessionRef.current = next;
setSession(next); setSession(next);
requestAnimationFrame(() => {
const label = labelRef.current;
if (label) {
label.style.left = `${next.x}px`;
label.style.top = `${next.y}px`;
}
});
}, },
unmount: () => setSession(null), unmount: () => {
bindElement: el => { sessionRef.current = null;
overlayRef.current = el as HTMLDivElement | null; setSession(null);
bindPaletteDragOverlayElement(null);
}, },
bindElement: () => { /* ref callback 에서 처리 */ },
}); });
return () => registerPaletteDragOverlay(null); return () => registerPaletteDragOverlay(null);
}, []); }, []);
useLayoutEffect(() => { useEffect(() => {
bindPaletteDragOverlayElement(overlayRef.current); return subscribePaletteDrag(ev => {
return () => bindPaletteDragOverlayElement(null); if (ev.phase !== 'move' && ev.phase !== 'start') return;
}, [session]); const label = labelRef.current;
if (!label) return;
label.style.left = `${ev.x}px`;
label.style.top = `${ev.y}px`;
});
}, []);
useLayoutEffect(() => { const overlayRefCallback = useCallback((el: HTMLDivElement | null) => {
if (!session || !overlayRef.current) return; bindPaletteDragOverlayElement(el);
try { const s = sessionRef.current;
overlayRef.current.setPointerCapture(session.pointerId); if (el && s) {
} catch { try {
/* ignore */ el.setPointerCapture(s.pointerId);
} catch {
/* window backup in paletteDragSession */
}
const label = labelRef.current;
if (label) {
label.style.left = `${s.x}px`;
label.style.top = `${s.y}px`;
}
} }
}, [session]); }, []);
const onPointerMove = useCallback((e: React.PointerEvent) => { const onPointerMove = useCallback((e: React.PointerEvent) => {
if (!session || e.pointerId !== session.pointerId) return; const s = sessionRef.current;
if (!s || e.pointerId !== s.pointerId) return;
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
const label = labelRef.current; const label = labelRef.current;
@@ -61,31 +75,60 @@ export default function PaletteDragOverlay() {
label.style.top = `${e.clientY}px`; label.style.top = `${e.clientY}px`;
} }
notifyPaletteDragMove(e.clientX, e.clientY); notifyPaletteDragMove(e.clientX, e.clientY);
}, [session]); }, []);
const onPointerUp = useCallback((e: React.PointerEvent) => { const onPointerUp = useCallback((e: React.PointerEvent) => {
if (!session || e.pointerId !== session.pointerId) return; const s = sessionRef.current;
if (!s || e.pointerId !== s.pointerId) return;
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
notifyPaletteDragEnd(e.clientX, e.clientY); notifyPaletteDragEnd(e.clientX, e.clientY);
}, [session]); }, []);
const onPointerCancel = useCallback((e: React.PointerEvent) => { const onPointerCancel = useCallback((e: React.PointerEvent) => {
if (!session || e.pointerId !== session.pointerId) return; const s = sessionRef.current;
if (!s || e.pointerId !== s.pointerId) return;
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
notifyPaletteDragCancel(e.clientX, e.clientY); notifyPaletteDragCancel(e.clientX, e.clientY);
}, [session]); }, []);
const onTouchMove = useCallback((e: React.TouchEvent) => {
const s = sessionRef.current;
if (!s || e.touches.length !== 1) return;
e.preventDefault();
const { x, y } = clientXYFromReact(e);
notifyPaletteDragMove(x, y);
}, []);
const onTouchEnd = useCallback((e: React.TouchEvent) => {
const s = sessionRef.current;
if (!s) return;
const { x, y } = clientXYFromReact(e);
e.preventDefault();
notifyPaletteDragEnd(x, y);
}, []);
const onTouchCancel = useCallback((e: React.TouchEvent) => {
const s = sessionRef.current;
if (!s) return;
const { x, y } = clientXYFromReact(e);
e.preventDefault();
notifyPaletteDragCancel(x, y);
}, []);
if (!session) return null; if (!session) return null;
return createPortal( return createPortal(
<div <div
ref={overlayRef} ref={overlayRefCallback}
className="se-palette-drag-overlay" className="se-palette-drag-overlay"
onPointerMove={onPointerMove} onPointerMove={onPointerMove}
onPointerUp={onPointerUp} onPointerUp={onPointerUp}
onPointerCancel={onPointerCancel} onPointerCancel={onPointerCancel}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
onTouchCancel={onTouchCancel}
> >
<div <div
ref={labelRef} ref={labelRef}
@@ -3,6 +3,7 @@ import { createPortal } from 'react-dom';
import type { DefType } from '../../utils/strategyEditorShared'; import type { DefType } from '../../utils/strategyEditorShared';
import { import {
handlePalettePointerDown, handlePalettePointerDown,
handlePaletteTouchStart,
needsPointerPaletteDrag, needsPointerPaletteDrag,
writePaletteHtmlDragData, writePaletteHtmlDragData,
type PaletteDragPayload, type PaletteDragPayload,
@@ -94,6 +95,16 @@ function SidewaysFilterChip({
handlePalettePointerDown(e, payload, item.label); handlePalettePointerDown(e, payload, item.label);
}; };
const onTouchStart = (e: React.TouchEvent) => {
hideHint();
const payload: PaletteDragPayload = {
type: 'sidewaysFilter',
value: item.id,
label: item.label,
};
handlePaletteTouchStart(e, payload, item.label);
};
const handleClick = () => onSelect(); const handleClick = () => onSelect();
const handleDoubleClick = (e: React.MouseEvent) => { const handleDoubleClick = (e: React.MouseEvent) => {
e.preventDefault(); e.preventDefault();
@@ -113,6 +124,7 @@ function SidewaysFilterChip({
className={`se-palette-card se-palette-card--range se-palette-card--range-${item.mode}${selected ? ' se-palette-card--selected' : ''}`} className={`se-palette-card se-palette-card--range se-palette-card--range-${item.mode}${selected ? ' se-palette-card--selected' : ''}`}
onDragStart={onDragStart} onDragStart={onDragStart}
onPointerDown={onPointerDown} onPointerDown={onPointerDown}
onTouchStart={onTouchStart}
onClick={handleClick} onClick={handleClick}
onDoubleClick={handleDoubleClick} onDoubleClick={handleDoubleClick}
onMouseEnter={e => showHint(e.currentTarget)} onMouseEnter={e => showHint(e.currentTarget)}
+150 -30
View File
@@ -1,7 +1,11 @@
/** /**
* Desktop(Tauri) — HTML5 DnD 대신 pointer + 전체화면 overlay 드래그 * Desktop(Tauri) 및 터치 기기 — HTML5 DnD 대신 pointer/touch + 전체화면 overlay 드래그
*/ */
import { elementsUnderDragPoint } from './flowScreenProjection'; import { elementsUnderDragPoint } from './flowScreenProjection';
import {
bindWindowDrag,
isPrimaryPointerButton,
} from './pointerDrag';
import { isDesktop } from './platform'; import { isDesktop } from './platform';
export type PaletteDragPayload = Record<string, unknown> & { export type PaletteDragPayload = Record<string, unknown> & {
@@ -40,15 +44,28 @@ const DRAG_THRESHOLD_PX = 6;
let activePayload: PaletteDragPayload | null = null; let activePayload: PaletteDragPayload | null = null;
let activePointerId: number | null = null; let activePointerId: number | null = null;
let activeTouchId: number | null = null;
let overlayController: OverlayController | null = null; let overlayController: OverlayController | null = null;
let overlayElement: HTMLElement | null = null; let overlayElement: HTMLElement | null = null;
let listeners = new Set<PaletteDragListener>(); let listeners = new Set<PaletteDragListener>();
let dropHandler: PaletteDropHandler | null = null; let dropHandler: PaletteDropHandler | null = null;
let moveRafId: number | null = null; let moveRafId: number | null = null;
let pendingMove: { x: number; y: number } | null = null; let pendingMove: { x: number; y: number } | null = null;
let unbindActiveDrag: (() => void) | null = null;
let pendingPointerListeners: (() => void) | null = null;
function hasCoarsePointer(): boolean {
if (typeof window === 'undefined') return false;
try {
return window.matchMedia('(pointer: coarse)').matches;
} catch {
return false;
}
}
/** HTML5 DnD 대신 pointer/touch overlay 드래그 사용 */
export function needsPointerPaletteDrag(): boolean { export function needsPointerPaletteDrag(): boolean {
return isDesktop(); return isDesktop() || hasCoarsePointer();
} }
export function subscribePaletteDrag(listener: PaletteDragListener): () => void { export function subscribePaletteDrag(listener: PaletteDragListener): () => void {
@@ -88,9 +105,36 @@ function dispatchEnd(xy: { x: number; y: number }, payload: PaletteDragPayload)
emit(event); emit(event);
} }
function releasePointerCapture(pointerId: number | null) {
if (pointerId == null) return;
try {
overlayElement?.releasePointerCapture(pointerId);
} catch {
/* ignore */
}
try {
if (document.body.hasPointerCapture(pointerId)) {
document.body.releasePointerCapture(pointerId);
}
} catch {
/* ignore */
}
}
function clearPendingPointerListeners() {
pendingPointerListeners?.();
pendingPointerListeners = null;
}
function cleanupDrag() { function cleanupDrag() {
const pointerId = activePointerId;
unbindActiveDrag?.();
unbindActiveDrag = null;
clearPendingPointerListeners();
releasePointerCapture(pointerId);
activePayload = null; activePayload = null;
activePointerId = null; activePointerId = null;
activeTouchId = null;
pendingMove = null; pendingMove = null;
if (moveRafId != null) { if (moveRafId != null) {
cancelAnimationFrame(moveRafId); cancelAnimationFrame(moveRafId);
@@ -98,6 +142,7 @@ function cleanupDrag() {
} }
document.body.classList.remove('se-palette-drag-active'); document.body.classList.remove('se-palette-drag-active');
overlayController?.unmount(); overlayController?.unmount();
overlayElement = null;
document.querySelectorAll('.se-palette-drag-ghost').forEach(el => el.remove()); document.querySelectorAll('.se-palette-drag-ghost').forEach(el => el.remove());
} }
@@ -140,6 +185,37 @@ export function completePaletteDragAt(clientX: number, clientY: number): boolean
return true; return true;
} }
function eventMatchesActiveInput(ev: Event): boolean {
if (!activePayload || activePointerId == null) return false;
if (ev instanceof PointerEvent) return ev.pointerId === activePointerId;
if (ev instanceof TouchEvent) {
if (activeTouchId != null) {
const touches = ev.touches.length > 0 ? ev.touches : ev.changedTouches;
return Array.from(touches).some(t => t.identifier === activeTouchId);
}
return ev.touches.length <= 1;
}
return true;
}
function bindActiveDragWindowListeners(pointerId: number) {
unbindActiveDrag?.();
unbindActiveDrag = bindWindowDrag(
(xy, ev) => {
if (!activePayload || activePointerId !== pointerId) return;
if (!eventMatchesActiveInput(ev)) return;
notifyPaletteDragMove(xy.x, xy.y);
},
(xy, ev) => {
if (!activePayload || activePointerId !== pointerId) return;
if (!eventMatchesActiveInput(ev)) return;
finishDrag('end', xy);
},
{ preventTouchScroll: true },
);
}
function captureOnOverlay(pointerId: number) { function captureOnOverlay(pointerId: number) {
try { try {
overlayElement?.setPointerCapture(pointerId); overlayElement?.setPointerCapture(pointerId);
@@ -159,12 +235,20 @@ function beginDrag(
payload: PaletteDragPayload, payload: PaletteDragPayload,
xy: { x: number; y: number }, xy: { x: number; y: number },
pointerId: number, pointerId: number,
touchId: number | null = null,
) { ) {
if (activePayload) {
cleanupDrag();
}
activePayload = payload; activePayload = payload;
activePointerId = pointerId; activePointerId = pointerId;
activeTouchId = touchId;
document.body.classList.add('se-palette-drag-active'); document.body.classList.add('se-palette-drag-active');
bindActiveDragWindowListeners(pointerId);
overlayController?.mount({ payload, pointerId, x: xy.x, y: xy.y }); overlayController?.mount({ payload, pointerId, x: xy.x, y: xy.y });
requestAnimationFrame(() => captureOnOverlay(pointerId)); requestAnimationFrame(() => captureOnOverlay(pointerId));
emit({ phase: 'start', x: xy.x, y: xy.y, payload }); emit({ phase: 'start', x: xy.x, y: xy.y, payload });
@@ -178,47 +262,83 @@ export function bindPaletteDragOverlayElement(el: HTMLElement | null) {
} }
} }
function startPaletteDragGesture(
startX: number,
startY: number,
pointerId: number,
touchId: number | null,
payload: PaletteDragPayload,
): void {
if (activePayload) {
cleanupDrag();
}
clearPendingPointerListeners();
let dragStarted = false;
const unbind = bindWindowDrag(
(xy, ev) => {
if (dragStarted) return;
const dx = xy.x - startX;
const dy = xy.y - startY;
if (Math.hypot(dx, dy) < DRAG_THRESHOLD_PX) return;
dragStarted = true;
clearPendingPointerListeners();
if (ev.cancelable) ev.preventDefault();
beginDrag(payload, xy, pointerId, touchId);
},
() => {
if (dragStarted) return;
clearPendingPointerListeners();
},
{ preventTouchScroll: true },
);
pendingPointerListeners = unbind;
}
export function handlePalettePointerDown( export function handlePalettePointerDown(
e: React.PointerEvent, e: React.PointerEvent,
payload: PaletteDragPayload, payload: PaletteDragPayload,
_label: string, _label: string,
): void { ): void {
if (!needsPointerPaletteDrag()) return; if (!needsPointerPaletteDrag()) return;
if (e.button !== 0) return; if (!isPrimaryPointerButton(e)) return;
if ((e.target as HTMLElement).closest('input, textarea, select, button')) return; if ((e.target as HTMLElement).closest('input, textarea, select, button')) return;
e.preventDefault(); e.preventDefault();
const startX = e.clientX; startPaletteDragGesture(
const startY = e.clientY; e.clientX,
const pointerId = e.pointerId; e.clientY,
e.pointerId,
null,
payload,
);
}
const onPointerMove = (ev: PointerEvent) => { /** PointerEvent 미지원 환경 — touchstart 전용 진입 */
if (ev.pointerId !== pointerId) return; export function handlePaletteTouchStart(
const dx = ev.clientX - startX; e: React.TouchEvent,
const dy = ev.clientY - startY; payload: PaletteDragPayload,
if (Math.hypot(dx, dy) < DRAG_THRESHOLD_PX) return; _label: string,
): void {
if (!needsPointerPaletteDrag()) return;
if (typeof window !== 'undefined' && 'PointerEvent' in window) return;
if (e.touches.length !== 1) return;
if ((e.target as HTMLElement).closest('input, textarea, select, button')) return;
window.removeEventListener('pointermove', onPointerMove); e.preventDefault();
window.removeEventListener('pointerup', onPointerUp);
window.removeEventListener('pointercancel', onPointerCancel);
ev.preventDefault(); const touch = e.touches[0];
beginDrag(payload, { x: ev.clientX, y: ev.clientY }, pointerId); startPaletteDragGesture(
}; touch.clientX,
touch.clientY,
const onPointerUp = (ev: PointerEvent) => { touch.identifier,
if (ev.pointerId !== pointerId) return; touch.identifier,
window.removeEventListener('pointermove', onPointerMove); payload,
window.removeEventListener('pointerup', onPointerUp); );
window.removeEventListener('pointercancel', onPointerCancel);
};
const onPointerCancel = onPointerUp;
window.addEventListener('pointermove', onPointerMove, { passive: false });
window.addEventListener('pointerup', onPointerUp);
window.addEventListener('pointercancel', onPointerCancel);
} }
export function writePaletteHtmlDragData(e: React.DragEvent, payload: PaletteDragPayload): void { export function writePaletteHtmlDragData(e: React.DragEvent, payload: PaletteDragPayload): void {
+15 -14
View File
@@ -50,18 +50,9 @@ fi
CONF="$WORK_TREE/desktop/src-tauri/tauri.conf.json" CONF="$WORK_TREE/desktop/src-tauri/tauri.conf.json"
# CI patch bump (jenkins-desktop-pipeline 에서 선행 bump 시 DESKTOP_SKIP_VERSION_BUMP=1) # ── 빌드 전 semver bump (기본: 항상 실행) ─────────────────────────────────────
if [[ "${DESKTOP_SKIP_VERSION_BUMP:-}" != "1" ]] \ # DESKTOP_NO_BUMP=1 로만 생략 (로컬 개발용)
&& [[ -z "${BUMP_VERSION:-}" ]] \ if [[ -n "${BUMP_VERSION:-}" ]]; then
&& [[ "${DESKTOP_BUMP_ON_CI:-1}" == "1" ]] \
&& [[ -n "${BUILD_NUMBER:-}${DESKTOP_AUTO_BUMP:-}" ]]; then
chmod +x "$WORK_TREE/scripts/bump-desktop-version-from-release.sh"
export WORK_TREE
BUMPED="$("$WORK_TREE/scripts/bump-desktop-version-from-release.sh")"
echo "Desktop CI version bump → $BUMPED"
fi
if [[ -n "$BUMP_VERSION" ]]; then
node -e " node -e "
const fs=require('fs'); const p=process.argv[1]; const b=process.argv[2]; const fs=require('fs'); const p=process.argv[1]; const b=process.argv[2];
const c=JSON.parse(fs.readFileSync(p,'utf8')); const c=JSON.parse(fs.readFileSync(p,'utf8'));
@@ -72,12 +63,22 @@ else if(b==='minor') nv=[a,x+1,0].join('.');
else if(b==='major') nv=[a+1,0,0].join('.'); else if(b==='major') nv=[a+1,0,0].join('.');
else nv=b; else nv=b;
c.version=nv; fs.writeFileSync(p, JSON.stringify(c,null,2)+'\n'); c.version=nv; fs.writeFileSync(p, JSON.stringify(c,null,2)+'\n');
console.log('version', nv); const pkgPath=p.replace('src-tauri/tauri.conf.json','package.json');
try{const pkg=JSON.parse(fs.readFileSync(pkgPath,'utf8'));pkg.version=nv;fs.writeFileSync(pkgPath,JSON.stringify(pkg,null,2)+'\n');}catch{}
console.log(nv);
" "$CONF" "$BUMP_VERSION" " "$CONF" "$BUMP_VERSION"
echo "=== Desktop version (explicit): v$(node -p "require('$CONF').version") ==="
elif [[ "${DESKTOP_NO_BUMP:-}" != "1" ]]; then
chmod +x "$WORK_TREE/scripts/bump-desktop-version-before-build.sh"
BEFORE_VER="$("$WORK_TREE/scripts/read-desktop-version.sh")"
BUMPED_VER="$("$WORK_TREE/scripts/bump-desktop-version-before-build.sh")"
echo "=== Desktop version bump: v${BEFORE_VER} → v${BUMPED_VER} ==="
else
echo "=== Desktop version bump SKIPPED (DESKTOP_NO_BUMP=1) — v$(node -p "require('$CONF').version") ==="
fi fi
VERSION="$(node -p "require('$CONF').version")" VERSION="$(node -p "require('$CONF').version")"
echo "Version: $VERSION" echo "=== Building GoldenChart Desktop v${VERSION} ==="
node -e " node -e "
const fs = require('fs'); const fs = require('fs');
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env bash
# 빌드 직전 semver patch bump (필수) — 실패 시 exit 1
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
WORK_TREE="${WORK_TREE:-$ROOT}"
export WORK_TREE
export DESKTOP_UPDATE_BASE_URL="${DESKTOP_UPDATE_BASE_URL:-https://exdev.co.kr/desktop/updates}"
CONF="$WORK_TREE/desktop/src-tauri/tauri.conf.json"
CARGO="$WORK_TREE/desktop/src-tauri/Cargo.toml"
PKG="$WORK_TREE/desktop/package.json"
BEFORE="$("$ROOT/scripts/read-desktop-version.sh")"
echo "[bump-before-build] current v${BEFORE}" >&2
chmod +x "$ROOT/scripts/bump-desktop-version-from-release.sh"
NEW="$("$ROOT/scripts/bump-desktop-version-from-release.sh")"
if [[ -z "$NEW" || "$NEW" == "$BEFORE" ]]; then
echo "[bump-before-build] ERROR: bump failed — still v${BEFORE}" >&2
echo " remote latest: ${DESKTOP_UPDATE_BASE_URL}/latest.json 확인" >&2
exit 1
fi
# semver strictly increased
if ! node -e "
const parse = v => String(v||'0').replace(/^v/i,'').split('.').map(n=>parseInt(n,10)||0);
const a=parse(process.argv[1]), b=parse(process.argv[2]);
for(let i=0;i<3;i++){const d=(a[i]||0)-(b[i]||0);if(d){process.exit(d>0?0:1)}}
process.exit(1);
" "$NEW" "$BEFORE"; then
echo "[bump-before-build] ERROR: v${NEW} is not greater than v${BEFORE}" >&2
exit 1
fi
# Cargo.toml (Tauri 일부 경로에서 참조)
if [[ -f "$CARGO" ]]; then
node -e "
const fs=require('fs');
const p=process.argv[1], v=process.argv[2];
let t=fs.readFileSync(p,'utf8');
if(!/^version\\s*=\\s*\"/m.test(t)){process.exit(0)}
t=t.replace(/^version\\s*=\\s*\"[^\"]+\"/m, 'version = \"'+v+'\"');
fs.writeFileSync(p,t);
" "$CARGO" "$NEW"
fi
echo "[bump-before-build] v${BEFORE} → v${NEW}" >&2
echo "$NEW"
+15 -25
View File
@@ -18,7 +18,6 @@ log "WORK_TREE=$WORK_TREE"
cd "$WORK_TREE" cd "$WORK_TREE"
# Git 동기화 (checkout → git 에 기록된 semver 그대로 빌드)
GIT_DIR="${GIT_DIR:-/Volumes/ADATA/git/goldenChart.git}" GIT_DIR="${GIT_DIR:-/Volumes/ADATA/git/goldenChart.git}"
if [[ -d "$GIT_DIR" ]]; then if [[ -d "$GIT_DIR" ]]; then
if git --git-dir="$GIT_DIR" remote get-url origin >/dev/null 2>&1; then if git --git-dir="$GIT_DIR" remote get-url origin >/dev/null 2>&1; then
@@ -27,15 +26,20 @@ if [[ -d "$GIT_DIR" ]]; then
git --git-dir="$GIT_DIR" --work-tree="$WORK_TREE" checkout -f main 2>/dev/null || true git --git-dir="$GIT_DIR" --work-tree="$WORK_TREE" checkout -f main 2>/dev/null || true
fi fi
# Jenkins SCM workspace 가 있으면 scripts 를 WORK_TREE 로 동기 (구 스크립트 방지)
if [[ -n "${WORKSPACE:-}" && -d "${WORKSPACE}/scripts" && "${WORKSPACE}" != "$WORK_TREE" ]]; then
log "Sync scripts: ${WORKSPACE}/scripts → ${WORK_TREE}/scripts"
rsync -a "${WORKSPACE}/scripts/" "${WORK_TREE}/scripts/" 2>/dev/null \
|| cp -f "${WORKSPACE}/scripts/"*.sh "${WORK_TREE}/scripts/" 2>/dev/null || true
fi
chmod +x "$WORK_TREE/scripts/"*.sh 2>/dev/null || true chmod +x "$WORK_TREE/scripts/"*.sh 2>/dev/null || true
# 의존성 (최초 1회 — 이미 있으면 빠르게 통과)
if ! command -v cargo-xwin >/dev/null 2>&1 || ! command -v rustc >/dev/null 2>&1; then if ! command -v cargo-xwin >/dev/null 2>&1 || ! command -v rustc >/dev/null 2>&1; then
log "Desktop build deps 설치..." log "Desktop build deps 설치..."
"$WORK_TREE/scripts/install-desktop-build-deps.sh" "$WORK_TREE/scripts/install-desktop-build-deps.sh"
fi fi
# updater pubkey (repo에 desktop/updater.pub 있으면 patch만)
if [[ -f "$WORK_TREE/desktop/updater.pub" ]]; then if [[ -f "$WORK_TREE/desktop/updater.pub" ]]; then
"$WORK_TREE/scripts/patch-tauri-updater-pubkey.sh" "$WORK_TREE/scripts/patch-tauri-updater-pubkey.sh"
elif [[ -f "${TAURI_SIGNER_KEY:-$HOME/.tauri/goldenchart.key}" ]]; then elif [[ -f "${TAURI_SIGNER_KEY:-$HOME/.tauri/goldenchart.key}" ]]; then
@@ -54,47 +58,33 @@ export BUILD_NUMBER="${BUILD_NUMBER:-}"
export DESKTOP_UPDATE_BASE_URL="${DESKTOP_UPDATE_BASE_URL:-https://exdev.co.kr/desktop/updates}" export DESKTOP_UPDATE_BASE_URL="${DESKTOP_UPDATE_BASE_URL:-https://exdev.co.kr/desktop/updates}"
GIT_VER="$(node -p "require('$WORK_TREE/desktop/src-tauri/tauri.conf.json').version" 2>/dev/null || echo '?')" GIT_VER="$(node -p "require('$WORK_TREE/desktop/src-tauri/tauri.conf.json').version" 2>/dev/null || echo '?')"
log "Desktop git version: v${GIT_VER}" log "Desktop git tauri.conf: v${GIT_VER}"
log "semver bump → build-desktop.sh 시작 시 자동 실행 (bump-desktop-version-before-build.sh)"
# 매 Jenkins 빌드마다 published latest + git 중 max → +1 (동일 버전 재빌드 방지)
if [[ -z "${BUMP_VERSION:-}" ]] && [[ "${DESKTOP_BUMP_ON_CI:-1}" == "1" ]]; then
log "CI Desktop version bump (published latest + git → +1 patch)..."
BUMPED="$("$WORK_TREE/scripts/bump-desktop-version-from-release.sh")"
export DESKTOP_SKIP_VERSION_BUMP=1
log "Desktop build version → v${BUMPED}"
else
export DESKTOP_SKIP_VERSION_BUMP=1
BUMPED="$GIT_VER"
log "Desktop version bump skipped — using git v${BUMPED}"
fi
"$WORK_TREE/scripts/build-desktop.sh" "${BUILD_ARGS[@]}" "$WORK_TREE/scripts/build-desktop.sh" "${BUILD_ARGS[@]}"
BUILT_VER="$(node -p "require('$WORK_TREE/dist-desktop/build-info.json').version" 2>/dev/null || echo '?')"
log "Built version: v${BUILT_VER}"
WORK_TREE="$WORK_TREE" \ WORK_TREE="$WORK_TREE" \
ARTIFACT_DIR="$WORK_TREE/dist-desktop" \ ARTIFACT_DIR="$WORK_TREE/dist-desktop" \
DESKTOP_UPDATE_BASE_URL="${DESKTOP_UPDATE_BASE_URL:-https://exdev.co.kr/desktop/updates}" \ DESKTOP_UPDATE_BASE_URL="${DESKTOP_UPDATE_BASE_URL:-https://exdev.co.kr/desktop/updates}" \
TAURI_SIGNER_KEY="${TAURI_SIGNER_KEY:-$HOME/.tauri/goldenchart.key}" \ TAURI_SIGNER_KEY="${TAURI_SIGNER_KEY:-$HOME/.tauri/goldenchart.key}" \
DESKTOP_RELEASE_NOTES="${DESKTOP_RELEASE_NOTES:-Build ${BUILD_NUMBER:-local}}" \ DESKTOP_RELEASE_NOTES="${DESKTOP_RELEASE_NOTES:-Build ${BUILD_NUMBER:-local} v${BUILT_VER}}" \
"$WORK_TREE/scripts/publish-desktop-update.sh" "$WORK_TREE/scripts/publish-desktop-update.sh"
# latest.json·설치 파일 nginx 즉시 반영 (볼륨 마운트)
if [[ -x "$WORK_TREE/scripts/reload-frontend-desktop-static.sh" ]]; then if [[ -x "$WORK_TREE/scripts/reload-frontend-desktop-static.sh" ]]; then
WORK_TREE="$WORK_TREE" "$WORK_TREE/scripts/reload-frontend-desktop-static.sh" || true WORK_TREE="$WORK_TREE" "$WORK_TREE/scripts/reload-frontend-desktop-static.sh" || true
fi fi
# Jenkins artifact archive 경로
if [[ -d "$WORK_TREE/dist-desktop" ]]; then if [[ -d "$WORK_TREE/dist-desktop" ]]; then
log "Artifacts ready: $WORK_TREE/dist-desktop" log "Artifacts ready: $WORK_TREE/dist-desktop"
fi fi
# 웹 frontend Docker 재배포 (latest.json static 포함) — 선택
if [[ "${DESKTOP_DEPLOY_WEB:-0}" == "1" ]] && [[ -x "${DEPLOY_SCRIPT:-/Volumes/ADATA/git/deploy.sh}" ]]; then if [[ "${DESKTOP_DEPLOY_WEB:-0}" == "1" ]] && [[ -x "${DEPLOY_SCRIPT:-/Volumes/ADATA/git/deploy.sh}" ]]; then
log "Trigger web deploy (frontend only)..." log "Trigger web deploy (frontend only)..."
SERVICE_NAME=frontend "${DEPLOY_SCRIPT:-/Volumes/ADATA/git/deploy.sh}" SERVICE_NAME=frontend "${DEPLOY_SCRIPT:-/Volumes/ADATA/git/deploy.sh}"
fi fi
log "Pipeline complete." log "Pipeline complete — v${BUILT_VER}"
log "Published target: ${DESKTOP_UPDATE_BASE_URL:-https://exdev.co.kr/desktop/updates}/latest.json" log "Published: ${DESKTOP_UPDATE_BASE_URL}/latest.json"
if [[ -f "$WORK_TREE/dist-desktop/build-info.json" ]]; then
log "build-info: $(cat "$WORK_TREE/dist-desktop/build-info.json" 2>/dev/null | tr -d '\n' | head -c 200)"
fi
+23
View File
@@ -32,6 +32,29 @@ if [[ -f "$BUILD_INFO" ]]; then
fi fi
NOTES="${DESKTOP_RELEASE_NOTES:-GoldenChart Desktop $VERSION}" NOTES="${DESKTOP_RELEASE_NOTES:-GoldenChart Desktop $VERSION}"
# 동일 버전 재배포 방지 (updater 가 '이미 최신' 으로 보이는 원인)
PUBLISHED_LATEST_URL="${DESKTOP_PUBLISHED_LATEST_URL:-${BASE_URL}/latest.json}"
REMOTE_PUB_VER=""
if REMOTE_JSON="$(curl -sf --connect-timeout 5 --max-time 12 "$PUBLISHED_LATEST_URL" 2>/dev/null)"; then
REMOTE_PUB_VER="$(printf '%s' "$REMOTE_JSON" | node -e "
let d=''; process.stdin.on('data',c=>d+=c);
process.stdin.on('end',()=>{ try { console.log(JSON.parse(d).version||''); } catch { console.log(''); } });
" 2>/dev/null || true)"
fi
if [[ -n "$REMOTE_PUB_VER" ]]; then
if ! node -e "
const parse = v => String(v||'0').replace(/^v/i,'').split('.').map(n=>parseInt(n,10)||0);
const a=parse(process.argv[1]), b=parse(process.argv[2]);
for(let i=0;i<3;i++){const d=(a[i]||0)-(b[i]||0);if(d){process.exit(d>0?0:1)}}
process.exit(1);
" "$VERSION" "$REMOTE_PUB_VER"; then
log "ERROR: publish v${VERSION} <= remote v${REMOTE_PUB_VER} — bump 가 실행되지 않았습니다." >&2
log " build-desktop.sh 가 bump-desktop-version-before-build.sh 를 호출하는지 확인" >&2
exit 1
fi
log "remote v${REMOTE_PUB_VER} → publish v${VERSION} OK"
fi
log "=== Publish Desktop v$VERSION ===" log "=== Publish Desktop v$VERSION ==="
log "Work tree: $WORK_TREE" log "Work tree: $WORK_TREE"
log "Updates src: $UPDATES_SRC" log "Updates src: $UPDATES_SRC"
@@ -54,12 +54,9 @@ export PATH="/opt/homebrew/opt/llvm/bin:/opt/homebrew/bin:/opt/homebrew/sbin:/us
export WORK_TREE="${WORK_TREE}" export WORK_TREE="${WORK_TREE}"
export GIT_DIR="${GIT_DIR}" export GIT_DIR="${GIT_DIR}"
export BUILD_NUMBER="\${BUILD_NUMBER}" export BUILD_NUMBER="\${BUILD_NUMBER}"
export DESKTOP_AUTO_BUMP=1
export DESKTOP_BUMP_ON_CI=1
export DESKTOP_UPDATE_BASE_URL="${DESKTOP_UPDATE_BASE_URL:-https://exdev.co.kr/desktop/updates}" export DESKTOP_UPDATE_BASE_URL="${DESKTOP_UPDATE_BASE_URL:-https://exdev.co.kr/desktop/updates}"
export TAURI_SIGNER_KEY="\${TAURI_SIGNER_KEY:-\$HOME/.tauri/goldenchart.key}" export TAURI_SIGNER_KEY="\${TAURI_SIGNER_KEY:-\$HOME/.tauri/goldenchart.key}"
export DESKTOP_DEPLOY_WEB=0 export DESKTOP_DEPLOY_WEB=0
export DESKTOP_UPDATE_BASE_URL="${DESKTOP_UPDATE_BASE_URL:-https://exdev.co.kr/desktop/updates}"
"${WORK_TREE}/scripts/jenkins-desktop-pipeline.sh" "${WORK_TREE}/scripts/jenkins-desktop-pipeline.sh"
</command> </command>
</hudson.tasks.Shell> </hudson.tasks.Shell>