diff --git a/frontend/src/components/strategyEditor/PaletteChip.tsx b/frontend/src/components/strategyEditor/PaletteChip.tsx index 216469f..054cca7 100644 --- a/frontend/src/components/strategyEditor/PaletteChip.tsx +++ b/frontend/src/components/strategyEditor/PaletteChip.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { handlePalettePointerDown, + handlePaletteTouchStart, needsPointerPaletteDrag, shouldSuppressPaletteClick, writePaletteHtmlDragData, @@ -55,6 +56,10 @@ export default function PaletteChip({ handlePalettePointerDown(e, buildPayload(), composite ? `${label} + ${label}` : label); }; + const onTouchStart = (e: React.TouchEvent) => { + handlePaletteTouchStart(e, buildPayload(), composite ? `${label} + ${label}` : label); + }; + const handleClick = () => { if (shouldSuppressPaletteClick()) return; 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' : ''}`} onDragStart={onDragStart} onPointerDown={onPointerDown} + onTouchStart={onTouchStart} onClick={handleClick} onDoubleClick={handleDoubleClick} role="button" diff --git a/frontend/src/components/strategyEditor/PaletteDragOverlay.tsx b/frontend/src/components/strategyEditor/PaletteDragOverlay.tsx index 4cc4ac3..7f99fa5 100644 --- a/frontend/src/components/strategyEditor/PaletteDragOverlay.tsx +++ b/frontend/src/components/strategyEditor/PaletteDragOverlay.tsx @@ -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 { bindPaletteDragOverlayElement, @@ -6,53 +6,67 @@ import { notifyPaletteDragEnd, notifyPaletteDragMove, registerPaletteDragOverlay, + subscribePaletteDrag, type PaletteDragOverlaySession, } 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() { const [session, setSession] = useState(null); - const overlayRef = useRef(null); + const sessionRef = useRef(null); const labelRef = useRef(null); + sessionRef.current = session; + useEffect(() => { registerPaletteDragOverlay({ mount: next => { + sessionRef.current = 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), - bindElement: el => { - overlayRef.current = el as HTMLDivElement | null; + unmount: () => { + sessionRef.current = null; + setSession(null); + bindPaletteDragOverlayElement(null); }, + bindElement: () => { /* ref callback 에서 처리 */ }, }); return () => registerPaletteDragOverlay(null); }, []); - useLayoutEffect(() => { - bindPaletteDragOverlayElement(overlayRef.current); - return () => bindPaletteDragOverlayElement(null); - }, [session]); + useEffect(() => { + return subscribePaletteDrag(ev => { + if (ev.phase !== 'move' && ev.phase !== 'start') return; + const label = labelRef.current; + if (!label) return; + label.style.left = `${ev.x}px`; + label.style.top = `${ev.y}px`; + }); + }, []); - useLayoutEffect(() => { - if (!session || !overlayRef.current) return; - try { - overlayRef.current.setPointerCapture(session.pointerId); - } catch { - /* ignore */ + const overlayRefCallback = useCallback((el: HTMLDivElement | null) => { + bindPaletteDragOverlayElement(el); + const s = sessionRef.current; + if (el && s) { + try { + 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) => { - if (!session || e.pointerId !== session.pointerId) return; + const s = sessionRef.current; + if (!s || e.pointerId !== s.pointerId) return; e.preventDefault(); e.stopPropagation(); const label = labelRef.current; @@ -61,31 +75,60 @@ export default function PaletteDragOverlay() { label.style.top = `${e.clientY}px`; } notifyPaletteDragMove(e.clientX, e.clientY); - }, [session]); + }, []); 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.stopPropagation(); notifyPaletteDragEnd(e.clientX, e.clientY); - }, [session]); + }, []); 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.stopPropagation(); 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; return createPortal(
{ + hideHint(); + const payload: PaletteDragPayload = { + type: 'sidewaysFilter', + value: item.id, + label: item.label, + }; + handlePaletteTouchStart(e, payload, item.label); + }; + const handleClick = () => onSelect(); const handleDoubleClick = (e: React.MouseEvent) => { 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' : ''}`} onDragStart={onDragStart} onPointerDown={onPointerDown} + onTouchStart={onTouchStart} onClick={handleClick} onDoubleClick={handleDoubleClick} onMouseEnter={e => showHint(e.currentTarget)} diff --git a/frontend/src/utils/paletteDragSession.ts b/frontend/src/utils/paletteDragSession.ts index db2d0c5..bd5e0bd 100644 --- a/frontend/src/utils/paletteDragSession.ts +++ b/frontend/src/utils/paletteDragSession.ts @@ -1,7 +1,11 @@ /** - * Desktop(Tauri) — HTML5 DnD 대신 pointer + 전체화면 overlay 드래그 + * Desktop(Tauri) 및 터치 기기 — HTML5 DnD 대신 pointer/touch + 전체화면 overlay 드래그 */ import { elementsUnderDragPoint } from './flowScreenProjection'; +import { + bindWindowDrag, + isPrimaryPointerButton, +} from './pointerDrag'; import { isDesktop } from './platform'; export type PaletteDragPayload = Record & { @@ -40,15 +44,28 @@ const DRAG_THRESHOLD_PX = 6; let activePayload: PaletteDragPayload | null = null; let activePointerId: number | null = null; +let activeTouchId: number | null = null; let overlayController: OverlayController | null = null; let overlayElement: HTMLElement | null = null; let listeners = new Set(); let dropHandler: PaletteDropHandler | null = null; let moveRafId: 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 { - return isDesktop(); + return isDesktop() || hasCoarsePointer(); } export function subscribePaletteDrag(listener: PaletteDragListener): () => void { @@ -88,9 +105,36 @@ function dispatchEnd(xy: { x: number; y: number }, payload: PaletteDragPayload) 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() { + const pointerId = activePointerId; + unbindActiveDrag?.(); + unbindActiveDrag = null; + clearPendingPointerListeners(); + releasePointerCapture(pointerId); activePayload = null; activePointerId = null; + activeTouchId = null; pendingMove = null; if (moveRafId != null) { cancelAnimationFrame(moveRafId); @@ -98,6 +142,7 @@ function cleanupDrag() { } document.body.classList.remove('se-palette-drag-active'); overlayController?.unmount(); + overlayElement = null; document.querySelectorAll('.se-palette-drag-ghost').forEach(el => el.remove()); } @@ -140,6 +185,37 @@ export function completePaletteDragAt(clientX: number, clientY: number): boolean 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) { try { overlayElement?.setPointerCapture(pointerId); @@ -159,12 +235,20 @@ function beginDrag( payload: PaletteDragPayload, xy: { x: number; y: number }, pointerId: number, + touchId: number | null = null, ) { + if (activePayload) { + cleanupDrag(); + } + activePayload = payload; activePointerId = pointerId; + activeTouchId = touchId; document.body.classList.add('se-palette-drag-active'); + bindActiveDragWindowListeners(pointerId); overlayController?.mount({ payload, pointerId, x: xy.x, y: xy.y }); + requestAnimationFrame(() => captureOnOverlay(pointerId)); 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( e: React.PointerEvent, payload: PaletteDragPayload, _label: string, ): void { if (!needsPointerPaletteDrag()) return; - if (e.button !== 0) return; + if (!isPrimaryPointerButton(e)) return; if ((e.target as HTMLElement).closest('input, textarea, select, button')) return; e.preventDefault(); - const startX = e.clientX; - const startY = e.clientY; - const pointerId = e.pointerId; + startPaletteDragGesture( + e.clientX, + e.clientY, + e.pointerId, + null, + payload, + ); +} - const onPointerMove = (ev: PointerEvent) => { - if (ev.pointerId !== pointerId) return; - const dx = ev.clientX - startX; - const dy = ev.clientY - startY; - if (Math.hypot(dx, dy) < DRAG_THRESHOLD_PX) return; +/** PointerEvent 미지원 환경 — touchstart 전용 진입 */ +export function handlePaletteTouchStart( + e: React.TouchEvent, + payload: PaletteDragPayload, + _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); - window.removeEventListener('pointerup', onPointerUp); - window.removeEventListener('pointercancel', onPointerCancel); + e.preventDefault(); - ev.preventDefault(); - beginDrag(payload, { x: ev.clientX, y: ev.clientY }, pointerId); - }; - - const onPointerUp = (ev: PointerEvent) => { - if (ev.pointerId !== pointerId) return; - window.removeEventListener('pointermove', onPointerMove); - 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); + const touch = e.touches[0]; + startPaletteDragGesture( + touch.clientX, + touch.clientY, + touch.identifier, + touch.identifier, + payload, + ); } export function writePaletteHtmlDragData(e: React.DragEvent, payload: PaletteDragPayload): void { diff --git a/scripts/build-desktop.sh b/scripts/build-desktop.sh index 887a035..ca286ad 100755 --- a/scripts/build-desktop.sh +++ b/scripts/build-desktop.sh @@ -50,18 +50,9 @@ fi CONF="$WORK_TREE/desktop/src-tauri/tauri.conf.json" -# CI patch bump (jenkins-desktop-pipeline 에서 선행 bump 시 DESKTOP_SKIP_VERSION_BUMP=1) -if [[ "${DESKTOP_SKIP_VERSION_BUMP:-}" != "1" ]] \ - && [[ -z "${BUMP_VERSION:-}" ]] \ - && [[ "${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 +# ── 빌드 전 semver bump (기본: 항상 실행) ───────────────────────────────────── +# DESKTOP_NO_BUMP=1 로만 생략 (로컬 개발용) +if [[ -n "${BUMP_VERSION:-}" ]]; then node -e " const fs=require('fs'); const p=process.argv[1]; const b=process.argv[2]; 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 nv=b; 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" + 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 VERSION="$(node -p "require('$CONF').version")" -echo "Version: $VERSION" +echo "=== Building GoldenChart Desktop v${VERSION} ===" node -e " const fs = require('fs'); diff --git a/scripts/bump-desktop-version-before-build.sh b/scripts/bump-desktop-version-before-build.sh new file mode 100755 index 0000000..09552dc --- /dev/null +++ b/scripts/bump-desktop-version-before-build.sh @@ -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" diff --git a/scripts/jenkins-desktop-pipeline.sh b/scripts/jenkins-desktop-pipeline.sh index 80970fb..a02af86 100755 --- a/scripts/jenkins-desktop-pipeline.sh +++ b/scripts/jenkins-desktop-pipeline.sh @@ -18,7 +18,6 @@ log "WORK_TREE=$WORK_TREE" cd "$WORK_TREE" -# Git 동기화 (checkout → git 에 기록된 semver 그대로 빌드) GIT_DIR="${GIT_DIR:-/Volumes/ADATA/git/goldenChart.git}" if [[ -d "$GIT_DIR" ]]; 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 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 -# 의존성 (최초 1회 — 이미 있으면 빠르게 통과) if ! command -v cargo-xwin >/dev/null 2>&1 || ! command -v rustc >/dev/null 2>&1; then log "Desktop build deps 설치..." "$WORK_TREE/scripts/install-desktop-build-deps.sh" fi -# updater pubkey (repo에 desktop/updater.pub 있으면 patch만) if [[ -f "$WORK_TREE/desktop/updater.pub" ]]; then "$WORK_TREE/scripts/patch-tauri-updater-pubkey.sh" 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}" 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}" - -# 매 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 +log "Desktop git tauri.conf: v${GIT_VER}" +log "semver bump → build-desktop.sh 시작 시 자동 실행 (bump-desktop-version-before-build.sh)" "$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" \ ARTIFACT_DIR="$WORK_TREE/dist-desktop" \ DESKTOP_UPDATE_BASE_URL="${DESKTOP_UPDATE_BASE_URL:-https://exdev.co.kr/desktop/updates}" \ 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" -# latest.json·설치 파일 nginx 즉시 반영 (볼륨 마운트) if [[ -x "$WORK_TREE/scripts/reload-frontend-desktop-static.sh" ]]; then WORK_TREE="$WORK_TREE" "$WORK_TREE/scripts/reload-frontend-desktop-static.sh" || true fi -# Jenkins artifact archive 경로 if [[ -d "$WORK_TREE/dist-desktop" ]]; then log "Artifacts ready: $WORK_TREE/dist-desktop" fi -# 웹 frontend Docker 재배포 (latest.json static 포함) — 선택 if [[ "${DESKTOP_DEPLOY_WEB:-0}" == "1" ]] && [[ -x "${DEPLOY_SCRIPT:-/Volumes/ADATA/git/deploy.sh}" ]]; then log "Trigger web deploy (frontend only)..." SERVICE_NAME=frontend "${DEPLOY_SCRIPT:-/Volumes/ADATA/git/deploy.sh}" fi -log "Pipeline complete." -log "Published target: ${DESKTOP_UPDATE_BASE_URL:-https://exdev.co.kr/desktop/updates}/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 +log "Pipeline complete — v${BUILT_VER}" +log "Published: ${DESKTOP_UPDATE_BASE_URL}/latest.json" diff --git a/scripts/publish-desktop-update.sh b/scripts/publish-desktop-update.sh index 1818c8e..006eaed 100755 --- a/scripts/publish-desktop-update.sh +++ b/scripts/publish-desktop-update.sh @@ -32,6 +32,29 @@ if [[ -f "$BUILD_INFO" ]]; then fi 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 "Work tree: $WORK_TREE" log "Updates src: $UPDATES_SRC" diff --git a/scripts/server-install-desktop-jenkins.sh b/scripts/server-install-desktop-jenkins.sh index 01c1d82..b658b2b 100755 --- a/scripts/server-install-desktop-jenkins.sh +++ b/scripts/server-install-desktop-jenkins.sh @@ -54,12 +54,9 @@ export PATH="/opt/homebrew/opt/llvm/bin:/opt/homebrew/bin:/opt/homebrew/sbin:/us export WORK_TREE="${WORK_TREE}" export GIT_DIR="${GIT_DIR}" 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 TAURI_SIGNER_KEY="\${TAURI_SIGNER_KEY:-\$HOME/.tauri/goldenchart.key}" 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"