diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml new file mode 100644 index 0000000..0336ee7 --- /dev/null +++ b/.forgejo/workflows/release.yml @@ -0,0 +1,73 @@ +name: release + +# Canonical tag-triggered desktop release built and published on Forgejo. +# GitLab CI (.gitlab-ci.yml) and GitHub Actions (.github/workflows/release.yml) +# remain alternate builders; all three converge on publish-forgejo-release.mjs +# so the Forgejo feed is the single update source. +# +# Required repository secrets: +# FORGEJO_TOKEN — PAT with write:package + write:repository +# WIN_CSC_LINK — base64 Authenticode PFX (public-trust) +# WIN_CSC_KEY_PASSWORD — PFX password +# WIN_CSC_EXPECTED_SIGNER_SUBJECT — exact certificate subject +# Release fails closed when signing material is absent. + +on: + push: + tags: + - "v*.*.*" + workflow_dispatch: + +jobs: + release-windows: + runs-on: windows + defaults: { run: { shell: pwsh } } + steps: + - name: checkout + env: { CI_TOKEN: "${{ github.token }}" } + run: | + $u = [Uri]$env:GITHUB_SERVER_URL + $url = "$($u.Scheme)://actions:$($env:CI_TOKEN)@$($u.Authority)/$($env:GITHUB_REPOSITORY).git" + if (-not (Test-Path .git)) { git init -q . } + if (git remote | Select-String -Quiet '^origin$') { git remote set-url origin $url } else { git remote add origin $url } + git fetch -q --depth 1 origin $env:GITHUB_REF + git checkout -q -f FETCH_HEAD + git clean -qfdx + + - name: 버전 정본 대조 + run: | + node scripts/ci/sync-version.mjs --check --tag "$env:GITHUB_REF_NAME" + + - name: 의존성 설치 + run: npm ci + + - name: 데스크톱 빌드 (서명 필수) + env: + WIN_CSC_LINK: "${{ secrets.WIN_CSC_LINK }}" + WIN_CSC_KEY_PASSWORD: "${{ secrets.WIN_CSC_KEY_PASSWORD }}" + WIN_CSC_EXPECTED_SIGNER_SUBJECT: "${{ secrets.WIN_CSC_EXPECTED_SIGNER_SUBJECT }}" + run: | + if (-not $env:WIN_CSC_LINK -or -not $env:WIN_CSC_KEY_PASSWORD) { + throw "WIN_CSC_LINK / WIN_CSC_KEY_PASSWORD 가 없으면 stable 릴리스를 게시할 수 없습니다." + } + if ($env:WIN_CSC_EXPECTED_SIGNER_SUBJECT -match '(?i)Everything2EverythingDev') { + throw "로컬 개발 인증서는 production 서명 identity가 아닙니다." + } + npm run build --workspace=@d3ro/desktop + Push-Location apps/desktop + npx electron-builder --win --x64 --config electron-builder.yml --publish never + Pop-Location + + - name: Windows 산출물 검증 + env: + WIN_CSC_EXPECTED_SIGNER_SUBJECT: "${{ secrets.WIN_CSC_EXPECTED_SIGNER_SUBJECT }}" + run: | + $releaseVersion = node -p "require('./release/product-version.json').version" + & scripts/ci/verify-windows-release-artifact.ps1 -ExpectedVersion $releaseVersion -ExpectedSignerSubject $env:WIN_CSC_EXPECTED_SIGNER_SUBJECT -ReleaseDirectory "apps/desktop/release/$releaseVersion" + + - name: Forgejo 릴리스 + feed 게시 + env: + FORGEJO_TOKEN: "${{ secrets.FORGEJO_TOKEN }}" + FORGEJO_REPO: "${{ github.server_url }}/${{ github.repository }}" + run: | + node scripts/ci/publish-forgejo-release.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 65d8b16..0694180 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -104,6 +104,12 @@ jobs: - name: Run Edge Function Contract Tests run: deno test --config server/supabase/functions/deno.json --allow-read --allow-env server/supabase/functions + - name: Check Cloudflare Worker Drain + run: deno check --no-config server/cloudflare-worker/src/push-drain.ts + + - name: Run Cloudflare Worker Tests + run: deno test --no-config --allow-read server/cloudflare-worker/src/push-drain.test.ts + # ────────────────────────────────────────────────────────────────── # 2. Automated Test Matrix (Windows / macOS / Ubuntu) # ────────────────────────────────────────────────────────────────── diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fbed353..2c06ddb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -577,3 +577,10 @@ jobs: overwrite_files: false env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Publish to Forgejo Release and Update Feed + env: + FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }} + FORGEJO_RELEASE_TAG: ${{ steps.release-identity.outputs.tag }} + FORGEJO_RELEASE_DIR: release-dist + run: node scripts/ci/publish-forgejo-release.mjs diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 9bd9eae..3454c8d 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -484,6 +484,8 @@ publish-release: artifacts: false optional: true script: + # canonical: Forgejo feed + release hub. legacy mirror: GitLab registry. + - node scripts/ci/publish-forgejo-release.mjs - node scripts/ci/publish-gitlab-release.mjs rules: - if: '$CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+.*$/' diff --git a/apps/desktop/electron-builder.yml b/apps/desktop/electron-builder.yml index 3d1601a..8742df8 100644 --- a/apps/desktop/electron-builder.yml +++ b/apps/desktop/electron-builder.yml @@ -19,13 +19,13 @@ files: # 자동 업데이트 feed — 이 설정이 있어야 electron-builder가 # latest.yml / latest-mac.yml (update info)를 생성한다. # generic provider는 파일 생성만 트리거하고 직접 업로드는 하지 않음 -# (업로드는 scripts/ci/publish-gitlab-release.mjs 담당). -# d3r0/voice 프로젝트 ID = 1172 — +# (업로드는 scripts/ci/publish-forgejo-release.mjs 담당). +# canonical 호스트 = Forgejo (git.chanpaca.net/yunchan/d3ro-voice). # src/main/update-feed.ts의 UPDATE_FEED_URL과 반드시 동일 값 유지. # ──────────────────────────────────────────────────────────────────── publish: provider: generic - url: "https://gitlab.twentyoz.kr:8443/api/v4/projects/1172/packages/generic/d3ro-voice/latest" + url: "https://git.chanpaca.net/api/packages/yunchan/generic/d3ro-voice/latest" # prerelease 버전(0.1.1-alpha)에서 채널을 "alpha"로 감지해 alpha.yml을 만드는 동작 차단 — # electron-updater(기본 채널 latest)가 latest.yml을 찾으므로 항상 latest 채널로 고정. diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 3a5c2aa..ebf4195 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -9,6 +9,7 @@ "build": "electron-vite build", "preview": "electron-vite preview", "typecheck": "tsc --noEmit", + "typecheck:strict": "tsc --noEmit -p tsconfig.check.json && tsc --noEmit -p tsconfig.check-node.json", "lint": "eslint . --ext .ts,.tsx", "format": "prettier --write \"src/**/*.{ts,tsx,css}\"", "test": "vitest run --config vitest.config.ts", diff --git a/apps/desktop/src/main/services/UpdateService.ts b/apps/desktop/src/main/services/UpdateService.ts index 0797be7..bc7a146 100644 --- a/apps/desktop/src/main/services/UpdateService.ts +++ b/apps/desktop/src/main/services/UpdateService.ts @@ -1,19 +1,38 @@ // src/main/services/UpdateService.ts // electron-updater 기반 자동 업데이트. 싱글톤 + EventEmitter. // -// feed: public GitLab Generic Registry `d3ro-voice/latest` +// feed: Forgejo Generic Registry `d3ro-voice/latest` (canonical) +// 정책: release/update-policy.json (채널, 최소 지원 버전, 강제 업데이트, full/delta) // 기능: -// 1. 사용자 인가 기반 다운로드 (autoDownload=false) -// 2. 이번 버전 건너뛰기 (Skip This Version) 지원 -// 3. 차분 다운로드 (.blockmap) 및 실시간 프로그레스 스트리밍 -// 4. 프로세스 락 충돌 방지 및 안전한 재시작 (quitAndInstall) +// 1. 채널(latest/beta/alpha) 선택 및 prerelease 게이팅 +// 2. 사용자 인가 기반 다운로드 (autoDownload=false), 강제 업데이트 예외 +// 3. 이번 버전 건너뛰기 (Skip This Version) — 비강제일 때만 +// 4. major/버전갭 시 차분(.blockmap) 대신 전체 설치자 +// 5. staged rollout + 원격 킬 스위치 +// 6. 프로세스 락 충돌 방지 및 안전한 재시작 (quitAndInstall) import { EventEmitter } from 'events' +import { randomUUID } from 'node:crypto' import { app, dialog } from 'electron' import { getLogger } from './LoggerService' -import { configGet, configSet } from './ConfigService' +import { configGet, configGetAll, configSet } from './ConfigService' import { getMainWindow } from '../windows/WindowManager' -import { UPDATE_FEED_URL } from '../update-feed' +import { + UPDATE_FEED_URL, + UPDATE_POLICY_URL, + isUpdateChannel, + type UpdateChannel, +} from '../update-feed' +import { + DEFAULT_UPDATE_POLICY, + decideUpdate, + isChannelAcceptable, + isWithinRollout, + majorOf, + parseUpdatePolicy, + type UpdateDecision, + type UpdatePolicy, +} from '../update-policy' const logger = getLogger('UpdateService') @@ -21,6 +40,10 @@ const logger = getLogger('UpdateService') const INITIAL_CHECK_DELAY_MS = 15_000 /** 주기 체크 간격 (4시간) */ const CHECK_INTERVAL_MS = 4 * 60 * 60 * 1000 +/** 원격 정책 fetch 타임아웃 */ +const POLICY_FETCH_TIMEOUT_MS = 8_000 +/** legacy 설치본이 저장한 skip 키 (AppConfig에 없는 과거 문자열 키) */ +const LEGACY_SKIPPED_VERSION_KEY = 'skipped_update_version' export interface UpdateProgressPayload { percent: number @@ -29,9 +52,24 @@ export interface UpdateProgressPayload { total: number } +export interface UpdateAvailablePayload { + version: string + currentVersion: string + channel: UpdateChannel + releaseNotes?: string + /** 연기·건너뛰기 불가 */ + isMandatory: boolean + /** major 승격 여부 */ + isMajorUpgrade: boolean + /** 차분 대신 전체 설치자로 받는지 */ + isFullDownload: boolean + /** 강제 사유 (진단/로그) */ + reason: UpdateDecision['reason'] +} + export interface UpdateServiceEvents { 'checking-for-update': void - 'update-available': { version: string; releaseNotes?: string; isMandatory?: boolean } + 'update-available': UpdateAvailablePayload 'update-not-available': { version: string } 'download-progress': UpdateProgressPayload 'update-downloaded': { version: string } @@ -45,6 +83,9 @@ class UpdateService extends EventEmitter { private _promptShown = false private _autoUpdater: import('electron-updater').AppUpdater | null = null private _downloading = false + private _policy: UpdatePolicy = DEFAULT_UPDATE_POLICY + private _channel: UpdateChannel = 'latest' + private _forceFullDownload = false /** 자동 업데이트 시작. 비활성 조건이면 로그만 남기고 no-op. */ initialize(): void { @@ -79,6 +120,7 @@ class UpdateService extends EventEmitter { // 사용자 인가를 위해 자동 다운로드는 비활성화 (동의 시 downloadUpdate 호출) updater.autoDownload = false updater.autoInstallOnAppQuit = true + this._applyChannel(this._resolveChannel(DEFAULT_UPDATE_POLICY)) updater.logger = { info: (msg: unknown) => logger.info(String(msg)), warn: (msg: unknown) => logger.warn(String(msg)), @@ -92,20 +134,8 @@ class UpdateService extends EventEmitter { }) updater.on('update-available', (info) => { - logger.info(`업데이트 발견: v${info.version}`) - - const skippedVersion = configGet('skipped_update_version') as string | undefined - if (skippedVersion === info.version) { - logger.info(`사용자가 건너뛴 버전 v${info.version} — 프롬프트 생략`) - return - } - - this.emit('update-available', { - version: info.version, - releaseNotes: typeof info.releaseNotes === 'string' ? info.releaseNotes : undefined - }) - - void this._promptUserConsent(info.version, info.releaseNotes) + logger.info(`업데이트 발견: v${info.version} (채널 ${this._channel})`) + void this._handleUpdateAvailable(info) }) updater.on('update-not-available', (info) => { @@ -135,6 +165,8 @@ class UpdateService extends EventEmitter { this.emit('update-error', { message: err.message }) }) + // 원격 정책을 비동기로 내려받아 채널·강제 정책을 갱신한다. + void this._loadPolicy() this._initialTimer = setTimeout(() => this.checkForUpdates(), INITIAL_CHECK_DELAY_MS) this._intervalTimer = setInterval(() => this.checkForUpdates(), CHECK_INTERVAL_MS) logger.info(`자동 업데이트 활성 — feed: ${UPDATE_FEED_URL}`) @@ -143,6 +175,10 @@ class UpdateService extends EventEmitter { /** 수동 또는 주기적 업데이트 확인 */ checkForUpdates(): void { if (!this._autoUpdater || this._downloading) return + if (this._policy.killSwitch) { + logger.info('원격 킬 스위치 활성 — 업데이트 확인 중단') + return + } this._autoUpdater.checkForUpdates().catch((err: unknown) => { logger.warn(`checkForUpdates 실패: ${err instanceof Error ? err.message : String(err)}`) }) @@ -152,16 +188,35 @@ class UpdateService extends EventEmitter { async startDownload(): Promise { if (!this._autoUpdater || this._downloading) return this._downloading = true - logger.info('차분 업데이트 다운로드 시작 (.blockmap)') + logger.info( + this._forceFullDownload + ? '전체 설치자 다운로드 시작 (major/버전갭)' + : '차분 업데이트 다운로드 시작 (.blockmap)', + ) await this._autoUpdater.downloadUpdate() } /** 이번 버전 건너뛰기 설정 */ skipVersion(version: string): void { - configSet('skipped_update_version', version) + configSet('skippedUpdateVersion', version) logger.info(`버전 v${version} 건너뛰기 등록 완료`) } + /** 업데이트 채널을 전환한다. (설정 UI용) */ + setChannel(channel: UpdateChannel): void { + configSet('updateChannel', channel) + this._applyChannel(channel) + logger.info(`업데이트 채널 전환: ${channel}`) + } + + getChannel(): UpdateChannel { + return this._channel + } + + getPolicy(): UpdatePolicy { + return this._policy + } + dispose(): void { if (this._initialTimer) clearTimeout(this._initialTimer) if (this._intervalTimer) clearInterval(this._intervalTimer) @@ -169,29 +224,162 @@ class UpdateService extends EventEmitter { this._intervalTimer = null } + // ── 내부 ── + + private _resolveChannel(policy: UpdatePolicy): UpdateChannel { + const configured = configGet('updateChannel') + return isUpdateChannel(configured) ? configured : policy.defaultChannel + } + + private _applyChannel(channel: UpdateChannel): void { + this._channel = channel + if (!this._autoUpdater) return + const channelPolicy = this._policy.channels[channel] ?? { allowPrerelease: false } + this._autoUpdater.channel = channel + this._autoUpdater.allowPrerelease = channelPolicy.allowPrerelease + } + + private async _loadPolicy(): Promise { + if (!UPDATE_POLICY_URL) return + try { + const response = await fetch(UPDATE_POLICY_URL, { + cache: 'no-store', + signal: AbortSignal.timeout(POLICY_FETCH_TIMEOUT_MS), + }) + if (!response.ok) throw new Error(`HTTP ${response.status}`) + this._policy = parseUpdatePolicy(await response.json()) + this._applyChannel(this._resolveChannel(this._policy)) + logger.info( + `원격 업데이트 정책 적용 — channel=${this._channel}, min=${this._policy.minimumSupportedVersion}, killSwitch=${this._policy.killSwitch}`, + ) + } catch (err) { + // 네트워크 실패 시 내장 기본 정책으로 fail-open (업데이트 자체는 계속). + logger.debug( + `원격 업데이트 정책 로드 실패 — 내장 기본값 사용: ${err instanceof Error ? err.message : String(err)}`, + ) + } + } + + /** electron-updater의 차분 다운로드 여부를 설정한다 (NSIS 이외 업데이터는 무시). */ + private _setDifferentialDisabled(disabled: boolean): void { + if (!this._autoUpdater) return + ;(this._autoUpdater as unknown as { disableDifferentialDownload?: boolean }).disableDifferentialDownload = + disabled + } + + private _deviceId(): string { + const existing = configGet('updateDeviceId') + if (typeof existing === 'string' && existing.length > 0) return existing + const generated = randomUUID() + configSet('updateDeviceId', generated) + return generated + } + + private _skippedVersion(): string | null { + const current = configGet('skippedUpdateVersion') + if (typeof current === 'string' && current) return current + const legacy = (configGetAll() as unknown as Record)[LEGACY_SKIPPED_VERSION_KEY] + return typeof legacy === 'string' && legacy ? legacy : null + } + + private async _handleUpdateAvailable( + info: import('electron-updater').UpdateInfo, + ): Promise { + const currentVersion = app.getVersion() + const decision = decideUpdate(this._policy, currentVersion, info.version) + const channelPolicy = this._policy.channels[this._channel] ?? { allowPrerelease: false } + + if (!isChannelAcceptable(channelPolicy.allowPrerelease, currentVersion, info.version)) { + logger.info(`채널 ${this._channel} 정책상 v${info.version} 무시`) + return + } + + if (!decision.mandatory && this._skippedVersion() === info.version) { + logger.info(`사용자가 건너뛴 버전 v${info.version} — 프롬프트 생략`) + return + } + + if (!isWithinRollout(this._policy, this._deviceId(), info.version, decision)) { + logger.info( + `staged rollout(${this._policy.stagingPercentage}%) 밖 — v${info.version} 이번엔 노출하지 않음`, + ) + return + } + + // major 승격 또는 버전 갭이면 차분 패치를 시도하지 않는다. + this._forceFullDownload = decision.forceFull + this._setDifferentialDisabled(decision.forceFull) + + const currentMajor = majorOf(currentVersion) + const targetMajor = majorOf(info.version) + const isMajorUpgrade = + currentMajor !== null && targetMajor !== null && currentMajor !== targetMajor + + this.emit('update-available', { + version: info.version, + currentVersion, + channel: this._channel, + releaseNotes: typeof info.releaseNotes === 'string' ? info.releaseNotes : undefined, + isMandatory: decision.mandatory, + isMajorUpgrade, + isFullDownload: decision.forceFull, + reason: decision.reason, + }) + + await this._promptUserConsent( + info.version, + info.releaseNotes, + decision, + ) + } + /** 1단계: 신규 업데이트 발견 시 다운로드 인가 요청 다이얼로그 */ private async _promptUserConsent( version: string, - releaseNotes?: string | ReadonlyArray<{ version: string; note: string | null }> | null + releaseNotes: string | ReadonlyArray<{ version: string; note: string | null }> | null | undefined, + decision: UpdateDecision, ): Promise { - if (this._promptShown || this._downloading) return + if (this._downloading) return + // forceInstallBelow는 다이얼로그 없이 즉시 설치 (보안 하한선). + if (decision.mandatory && decision.reason === 'below-force-install') { + logger.info(`v${version} 강제 설치 (${decision.reason})`) + void this.startDownload() + return + } + if (this._promptShown) return this._promptShown = true const isKo = (configGet('language') as string | undefined)?.startsWith('ko') ?? true const notesText = typeof releaseNotes === 'string' ? `\n\n[주요 변경사항]\n${releaseNotes}` : '' + const mandatoryNote = decision.mandatory + ? isKo + ? '\n\n이 업데이트는 필수입니다 (지원 종료 버전).' + : '\n\nThis update is required (end of support).' + : '' - const options = { - type: 'info' as const, - title: isKo ? '새 버전 업데이트' : 'Software Update', - message: isKo - ? `D3RO Voice v${version} 새 버전이 출시되었습니다. 지금 다운로드할까요?${notesText}` - : `A new version of D3RO Voice (v${version}) is available. Would you like to download it now?${notesText}`, - buttons: isKo - ? ['지금 다운로드', '나중에', '이 버전 건너뛰기'] - : ['Download Now', 'Later', 'Skip This Version'], - defaultId: 0, - cancelId: 1, - } + const options = decision.mandatory + ? { + type: 'info' as const, + title: isKo ? '필수 업데이트' : 'Required Update', + message: isKo + ? `D3RO Voice v${version} 업데이트가 필요합니다.${mandatoryNote}${notesText}` + : `D3RO Voice v${version} is required.${mandatoryNote}${notesText}`, + buttons: isKo ? ['지금 업데이트'] : ['Update Now'], + defaultId: 0, + cancelId: -1, + } + : { + type: 'info' as const, + title: isKo ? '새 버전 업데이트' : 'Software Update', + message: isKo + ? `D3RO Voice v${version} 새 버전이 출시되었습니다. 지금 다운로드할까요?${notesText}` + : `A new version of D3RO Voice (v${version}) is available. Would you like to download it now?${notesText}`, + buttons: isKo + ? ['지금 다운로드', '나중에', '이 버전 건너뛰기'] + : ['Download Now', 'Later', 'Skip This Version'], + defaultId: 0, + cancelId: 1, + } const win = getMainWindow() const { response } = @@ -201,7 +389,7 @@ class UpdateService extends EventEmitter { if (response === 0) { void this.startDownload() - } else if (response === 2) { + } else if (!decision.mandatory && response === 2) { this.skipVersion(version) } } diff --git a/apps/desktop/src/main/update-feed.ts b/apps/desktop/src/main/update-feed.ts index fc87c47..484872f 100644 --- a/apps/desktop/src/main/update-feed.ts +++ b/apps/desktop/src/main/update-feed.ts @@ -1,15 +1,42 @@ // src/main/update-feed.ts // 자동 업데이트 feed URL SSOT. // -// release-create CI 잡이 GitLab Generic Package Registry의 -// `d3ro-voice/latest` 패키지에 latest.yml + 설치파일을 게시하며, -// electron-updater가 이 URL에서 latest.yml을 읽어 업데이트를 감지한다. +// canonical feed는 Forgejo Generic Package Registry의 `d3ro-voice/latest`다. +// `publish-forgejo-release.mjs`가 latest.yml + 설치파일 + update-policy.json을 +// 게시하면 electron-updater가 이 URL에서 latest.yml을 읽어 업데이트를 감지한다. // -// 전제: 프로젝트 설정에서 "Allow anyone to pull from Package Registry" 활성화 +// 전제: Forgejo에서 해당 owner/package의 무인증 pull이 허용돼야 한다 // (비활성 시 무인증 다운로드가 401 — 앱은 조용히 업데이트 체크를 건너뜀). // // 빈 문자열이면 UpdateService가 비활성 상태로 동작한다. -// d3r0/voice 프로젝트 ID = 1172. electron-builder.yml publish.url과 동일 값 유지. // 버전 없는 `latest` 패키지를 가리켜야 기존 설치본이 새 릴리스를 계속 찾을 수 있다. export const UPDATE_FEED_URL = + 'https://git.chanpaca.net/api/packages/yunchan/generic/d3ro-voice/latest' + +// GitLab Generic Registry legacy mirror. 2026-08 이전 설치본(0.2.1-alpha)은 +// 이 feed를 폴링하므로, 새 설치자가 Forgejo feed를 내장할 때까지 publisher가 +// 함께 게시한다. 마이그레이션 완료 후 제거 가능. 런타임은 참조하지 않는다. +export const LEGACY_UPDATE_FEED_URL = 'https://gitlab.twentyoz.kr:8443/api/v4/projects/1172/packages/generic/d3ro-voice/latest' + +/** 업데이트 채널. `latest`=stable, `beta`, `alpha` 순으로 불안정. */ +export type UpdateChannel = 'latest' | 'beta' | 'alpha' + +export const UPDATE_CHANNELS: readonly UpdateChannel[] = ['latest', 'beta', 'alpha'] + +/** 채널별 electron-builder update metadata 파일명. */ +export function channelMetadataName(channel: UpdateChannel): string { + return channel === 'latest' ? 'latest.yml' : `${channel}.yml` +} + +/** 원격 업데이트 정책 파일명. feed 루트에서 내려받는다. */ +export const UPDATE_POLICY_FILENAME = 'update-policy.json' + +/** 원격 업데이트 정책 URL (없으면 앱 내장 기본 정책으로 폴백). */ +export const UPDATE_POLICY_URL = UPDATE_FEED_URL + ? `${UPDATE_FEED_URL}/${UPDATE_POLICY_FILENAME}` + : '' + +export function isUpdateChannel(value: unknown): value is UpdateChannel { + return typeof value === 'string' && (UPDATE_CHANNELS as readonly string[]).includes(value) +} diff --git a/apps/desktop/src/main/update-policy.ts b/apps/desktop/src/main/update-policy.ts new file mode 100644 index 0000000..2b20270 --- /dev/null +++ b/apps/desktop/src/main/update-policy.ts @@ -0,0 +1,261 @@ +// src/main/update-policy.ts +// 업데이트 정책 SSOT (런타임). release/update-policy.json과 동일 스키마. +// +// 정책은 빌드에 내장된 기본값(DEFAULT_UPDATE_POLICY)으로 시작하고, +// feed의 `update-policy.json`을 내려받아 오버라이드할 수 있다. 서버가 강제 +// 업데이트·킬 스위치·staged rollout을 즉시 조정할 수 있게 하기 위함이다. +// +// 순수 로직만 두어 단위 테스트가 가능하도록 한다 (UpdateService는 I/O 담당). + +import { UPDATE_CHANNELS, isUpdateChannel, type UpdateChannel } from './update-feed' + +export interface UpdateChannelPolicy { + allowPrerelease: boolean +} + +export interface UpdatePolicy { + schemaVersion: number + defaultChannel: UpdateChannel + channels: Record + /** 이 버전 미만 클라이언트는 업데이트가 필수다 (연기·건너뛰기 불가). */ + minimumSupportedVersion: string + /** 이 버전 미만은 다이얼로그 없이 강제 설치한다. null이면 비활성. */ + forceInstallBelow: string | null + /** major가 달라지면 차분 대신 전체 설치자를 받는다. */ + fullInstallOnMajorChange: boolean + /** minor 갭이 이 값 이상이면 전체 설치자를 받는다. */ + fullInstallVersionGap: number + /** 이 비율(%)의 사용자에게만 stable 업데이트를 노출한다. 100=전체. */ + stagingPercentage: number + /** true면 업데이트 확인 자체를 중단한다 (원격 킬 스위치). */ + killSwitch: boolean +} + +export const DEFAULT_UPDATE_POLICY: UpdatePolicy = { + schemaVersion: 1, + defaultChannel: 'latest', + channels: { + latest: { allowPrerelease: false }, + beta: { allowPrerelease: true }, + alpha: { allowPrerelease: true }, + }, + minimumSupportedVersion: '0.0.0', + forceInstallBelow: null, + fullInstallOnMajorChange: true, + fullInstallVersionGap: 3, + stagingPercentage: 100, + killSwitch: false, +} + +export type UpdateDecisionReason = + | 'below-expiry' + | 'below-minimum-supported' + | 'below-force-install' + | 'standard' + +export interface UpdateDecision { + /** 연기·건너뛰기 불가 여부. */ + mandatory: boolean + /** 차분 대신 전체 설치자를 받아야 하는지. */ + forceFull: boolean + reason: UpdateDecisionReason +} + +interface ParsedVersion { + major: number + minor: number + patch: number + prerelease: readonly (string | number)[] +} + +/** semver의 major/minor/patch/prerelease를 파싱한다. 실패 시 null. */ +export function parseVersion(value: unknown): ParsedVersion | null { + if (typeof value !== 'string') return null + const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(value.trim()) + if (!match) return null + const prerelease = match[4] + ? match[4] + .split('.') + .map((part) => (/^\d+$/.test(part) ? Number(part) : part)) + : [] + return { + major: Number(match[1]), + minor: Number(match[2]), + patch: Number(match[3]), + prerelease, + } +} + +/** semver 비교. ab=1. 파싱 실패 시 null. */ +export function compareVersions(a: unknown, b: unknown): -1 | 0 | 1 | null { + const left = parseVersion(a) + const right = parseVersion(b) + if (!left || !right) return null + for (const key of ['major', 'minor', 'patch'] as const) { + if (left[key] !== right[key]) return left[key] < right[key] ? -1 : 1 + } + return comparePrerelease(left.prerelease, right.prerelease) +} + +function comparePrerelease( + left: readonly (string | number)[], + right: readonly (string | number)[], +): -1 | 0 | 1 { + // prerelease가 없으면 stable이 더 높다 (semver 11항). + if (left.length === 0 && right.length === 0) return 0 + if (left.length === 0) return 1 + if (right.length === 0) return -1 + const length = Math.max(left.length, right.length) + for (let index = 0; index < length; index++) { + const a = left[index] + const b = right[index] + if (a === undefined) return -1 + if (b === undefined) return 1 + if (a === b) continue + if (typeof a === 'number' && typeof b === 'number') return a < b ? -1 : 1 + if (typeof a === 'number') return -1 + if (typeof b === 'number') return 1 + return a < b ? -1 : 1 + } + return 0 +} + +/** major 버전 숫자. 파싱 실패 시 null. */ +export function majorOf(value: unknown): number | null { + return parseVersion(value)?.major ?? null +} + +/** + * 채널에 따라 업데이트를 적용할지 판단한다. 현재 버전이 prerelease이고 + * stable 채널이면 prerelease를 허용하지 않는 한 건너뛴다. + */ +export function isChannelAcceptable( + allowPrerelease: boolean, + currentVersion: string, + targetVersion: string, +): boolean { + const current = parseVersion(currentVersion) + const target = parseVersion(targetVersion) + if (!current || !target) return false + const currentIsPrerelease = current.prerelease.length > 0 + const targetIsPrerelease = target.prerelease.length > 0 + // stable 채널은 더 높은 prerelease로 올라가지 않는다. + if (!allowPrerelease && targetIsPrerelease && !currentIsPrerelease) return false + // prerelease가 아닌 현재 버전보다 낮은/같은 버전으로 내려가지 않는다. + const ordering = compareVersions(currentVersion, targetVersion) + if (ordering === null) return false + if (ordering >= 0) { + // 다운그레이드는 채널이 prerelease를 허용할 때만 (예: beta → stable 정렬). + return allowPrerelease ? ordering >= 0 : false + } + return true +} + +/** 현재 버전·대상 버전·정책으로 업데이트 결정을 계산한다. */ +export function decideUpdate( + policy: UpdatePolicy, + currentVersion: string, + targetVersion: string, +): UpdateDecision { + const current = parseVersion(currentVersion) + const target = parseVersion(targetVersion) + if (!current || !target) { + // 현재 버전을 신뢰할 수 없으면 차분 패치를 시도하지 않는다. + return { mandatory: false, forceFull: true, reason: 'standard' } + } + + let mandatory = false + let reason: UpdateDecisionReason = 'standard' + + if (compareVersions(currentVersion, policy.minimumSupportedVersion) === -1) { + mandatory = true + reason = 'below-minimum-supported' + } + if ( + policy.forceInstallBelow !== null && + compareVersions(currentVersion, policy.forceInstallBelow) === -1 + ) { + mandatory = true + reason = 'below-force-install' + } + + const majorChanged = current.major !== target.major + const minorGap = target.minor - current.minor + const forceFull = majorChanged + ? policy.fullInstallOnMajorChange + : minorGap >= policy.fullInstallVersionGap + + return { mandatory, forceFull, reason } +} + +/** 기기 ID와 버전으로 0..99 staged rollout 버킷을 계산한다 (영구적, 무작위적). */ +export function rolloutBucket(deviceId: string, version: string): number { + let hash = 2166136261 + const input = `${deviceId}:${version}` + for (let index = 0; index < input.length; index++) { + hash ^= input.charCodeAt(index) + hash = Math.imul(hash, 16777619) + } + return (hash >>> 0) % 100 +} + +/** staged rollout에 포함되는지 판단한다. */ +export function isWithinRollout( + policy: UpdatePolicy, + deviceId: string, + version: string, + decision: UpdateDecision, +): boolean { + // 강제 업데이트는 staged rollout을 무시한다. + if (decision.mandatory) return true + const percentage = Math.min(100, Math.max(0, Math.floor(policy.stagingPercentage))) + if (percentage >= 100) return true + if (percentage <= 0) return false + return rolloutBucket(deviceId, version) < percentage +} + +/** 알 수 없는 JSON을 검증·정규화해 정책으로 만든다. 실패 필드는 기본값 유지. */ +export function parseUpdatePolicy(raw: unknown): UpdatePolicy { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return DEFAULT_UPDATE_POLICY + const record = raw as Record + const policy: UpdatePolicy = { + ...DEFAULT_UPDATE_POLICY, + channels: { ...DEFAULT_UPDATE_POLICY.channels }, + } + + if (record.schemaVersion === 1) policy.schemaVersion = 1 + if (isUpdateChannel(record.defaultChannel)) policy.defaultChannel = record.defaultChannel + if (typeof record.minimumSupportedVersion === 'string' && parseVersion(record.minimumSupportedVersion)) { + policy.minimumSupportedVersion = record.minimumSupportedVersion + } + if (typeof record.forceInstallBelow === 'string' && parseVersion(record.forceInstallBelow)) { + policy.forceInstallBelow = record.forceInstallBelow + } else if (record.forceInstallBelow === null) { + policy.forceInstallBelow = null + } + if (typeof record.fullInstallOnMajorChange === 'boolean') { + policy.fullInstallOnMajorChange = record.fullInstallOnMajorChange + } + if (Number.isSafeInteger(record.fullInstallVersionGap) && (record.fullInstallVersionGap as number) >= 0) { + policy.fullInstallVersionGap = record.fullInstallVersionGap as number + } + if (Number.isSafeInteger(record.stagingPercentage)) { + policy.stagingPercentage = Math.min(100, Math.max(0, record.stagingPercentage as number)) + } + if (typeof record.killSwitch === 'boolean') policy.killSwitch = record.killSwitch + + if (record.channels && typeof record.channels === 'object' && !Array.isArray(record.channels)) { + const channels = record.channels as Record + for (const channel of UPDATE_CHANNELS) { + const entry = channels[channel] + if (entry && typeof entry === 'object' && !Array.isArray(entry)) { + const allowPrerelease = (entry as Record).allowPrerelease + if (typeof allowPrerelease === 'boolean') { + policy.channels[channel] = { allowPrerelease } + } + } + } + } + + return policy +} diff --git a/apps/desktop/tests/main/update-policy.test.ts b/apps/desktop/tests/main/update-policy.test.ts new file mode 100644 index 0000000..c1a5af6 --- /dev/null +++ b/apps/desktop/tests/main/update-policy.test.ts @@ -0,0 +1,163 @@ +// tests/main/update-policy.test.ts +// 업데이트 정책 순수 로직 테스트: 버전 비교, 메이저/증분 판단, 강제 업데이트, +// 채널 게이팅, staged rollout. + +import { describe, it, expect } from 'vitest' +import { + DEFAULT_UPDATE_POLICY, + compareVersions, + decideUpdate, + isChannelAcceptable, + isWithinRollout, + parseUpdatePolicy, + rolloutBucket, + type UpdatePolicy, +} from '../../src/main/update-policy' +import { channelMetadataName, isUpdateChannel } from '../../src/main/update-feed' + +const policy: UpdatePolicy = { + ...DEFAULT_UPDATE_POLICY, + minimumSupportedVersion: '1.0.0', + forceInstallBelow: '0.3.0', + fullInstallOnMajorChange: true, + fullInstallVersionGap: 3, + stagingPercentage: 100, +} + +describe('compareVersions', () => { + it('주/부/패치를 순서대로 비교한다', () => { + expect(compareVersions('1.2.3', '1.2.3')).toBe(0) + expect(compareVersions('1.2.3', '1.2.4')).toBe(-1) + expect(compareVersions('1.3.0', '1.2.9')).toBe(1) + expect(compareVersions('2.0.0', '1.99.99')).toBe(1) + }) + + it('prerelease는 같은 코어의 stable보다 낮다', () => { + expect(compareVersions('1.0.0-alpha', '1.0.0')).toBe(-1) + expect(compareVersions('1.1.0-beta.2', '1.1.0-beta.10')).toBe(-1) + }) + + it('v 접두와 build metadata를 허용한다', () => { + expect(compareVersions('v1.1.0', '1.1.0+build.7')).toBe(0) + }) + + it('파싱 실패 시 null', () => { + expect(compareVersions('not-a-version', '1.0.0')).toBeNull() + }) +}) + +describe('decideUpdate', () => { + it('최소 지원 버전 미만이면 강제 업데이트', () => { + const decision = decideUpdate(policy, '0.5.0', '1.1.0') + expect(decision.mandatory).toBe(true) + expect(decision.reason).toBe('below-minimum-supported') + }) + + it('forceInstallBelow 미만이면 강제 사유가 force-install (더 강한 조건이 우선)', () => { + const decision = decideUpdate(policy, '0.2.1-alpha', '1.1.0') + expect(decision.mandatory).toBe(true) + expect(decision.reason).toBe('below-force-install') + }) + + it('최소 지원 이상이면 일반 업데이트', () => { + const decision = decideUpdate(policy, '1.1.0', '1.2.0') + expect(decision.mandatory).toBe(false) + expect(decision.reason).toBe('standard') + expect(decision.forceFull).toBe(false) + }) + + it('major 승격이면 full 다운로드', () => { + const decision = decideUpdate(policy, '1.9.0', '2.0.0') + expect(decision.forceFull).toBe(true) + expect(decision.mandatory).toBe(false) + }) + + it('minor 갭이 임계 이상이면 full 다운로드', () => { + const decision = decideUpdate(policy, '1.0.0', '1.5.0') + expect(decision.forceFull).toBe(true) + }) + + it('major 정책이 꺼져 있으면 major 승격도 차분 가능', () => { + const relaxed = { ...policy, fullInstallOnMajorChange: false } + expect(decideUpdate(relaxed, '1.9.0', '2.0.0').forceFull).toBe(false) + }) + + it('현재 버전을 파싱할 수 없으면 전체 설치', () => { + expect(decideUpdate(policy, 'garbage', '1.2.0').forceFull).toBe(true) + }) +}) + +describe('isChannelAcceptable', () => { + it('stable 채널은 prerelease를 받지 않는다', () => { + expect(isChannelAcceptable(false, '1.0.0', '1.1.0-beta')).toBe(false) + expect(isChannelAcceptable(false, '1.0.0', '1.1.0')).toBe(true) + }) + + it('beta 채널은 prerelease를 받는다', () => { + expect(isChannelAcceptable(true, '1.0.0', '1.1.0-beta')).toBe(true) + }) + + it('더 높은 버전이 아니면 거부한다', () => { + expect(isChannelAcceptable(false, '1.2.0', '1.1.0')).toBe(false) + }) +}) + +describe('staged rollout', () => { + it('deviceId에 대해 결정적 버킷을 돌려준다', () => { + expect(rolloutBucket('device-a', '1.1.0')).toBe(rolloutBucket('device-a', '1.1.0')) + expect(rolloutBucket('device-a', '1.1.0')).toBeLessThan(100) + }) + + it('100%는 모두 포함, 0%는 모두 제외', () => { + const standard = { mandatory: false, forceFull: false, reason: 'standard' as const } + expect(isWithinRollout({ ...policy, stagingPercentage: 100 }, 'd', '1.1.0', standard)).toBe(true) + expect(isWithinRollout({ ...policy, stagingPercentage: 0 }, 'd', '1.1.0', standard)).toBe(false) + }) + + it('강제 업데이트는 rollout을 무시한다', () => { + const forced = { mandatory: true, forceFull: true, reason: 'below-force-install' as const } + expect(isWithinRollout({ ...policy, stagingPercentage: 0 }, 'd', '1.1.0', forced)).toBe(true) + }) +}) + +describe('parseUpdatePolicy', () => { + it('유효한 필드를 반영하고 잘못된 필드는 기본값 유지', () => { + const parsed = parseUpdatePolicy({ + schemaVersion: 1, + defaultChannel: 'beta', + minimumSupportedVersion: '1.2.0', + forceInstallBelow: null, + fullInstallOnMajorChange: false, + fullInstallVersionGap: 5, + stagingPercentage: 250, + killSwitch: true, + channels: { latest: { allowPrerelease: false }, rogue: {} }, + }) + expect(parsed.defaultChannel).toBe('beta') + expect(parsed.minimumSupportedVersion).toBe('1.2.0') + expect(parsed.forceInstallBelow).toBeNull() + expect(parsed.fullInstallOnMajorChange).toBe(false) + expect(parsed.fullInstallVersionGap).toBe(5) + expect(parsed.stagingPercentage).toBe(100) + expect(parsed.killSwitch).toBe(true) + expect(parsed.channels.beta.allowPrerelease).toBe(DEFAULT_UPDATE_POLICY.channels.beta.allowPrerelease) + }) + + it('잘못된 입력은 기본 정책', () => { + expect(parseUpdatePolicy(null)).toEqual(DEFAULT_UPDATE_POLICY) + expect(parseUpdatePolicy('nope')).toEqual(DEFAULT_UPDATE_POLICY) + }) +}) + +describe('update-feed helpers', () => { + it('채널별 metadata 파일명', () => { + expect(channelMetadataName('latest')).toBe('latest.yml') + expect(channelMetadataName('beta')).toBe('beta.yml') + expect(channelMetadataName('alpha')).toBe('alpha.yml') + }) + + it('채널 식별', () => { + expect(isUpdateChannel('latest')).toBe(true) + expect(isUpdateChannel('canary')).toBe(false) + }) +}) diff --git a/package-lock.json b/package-lock.json index cffcd21..57cdc57 100644 --- a/package-lock.json +++ b/package-lock.json @@ -154,40 +154,6 @@ "@rollup/rollup-win32-x64-msvc": "^4.60.1" } }, - "apps/mobile": { - "name": "@d3ro/mobile", - "version": "1.1.0", - "extraneous": true, - "dependencies": { - "@d3ro/api-client": "file:../../packages/api-client", - "@d3ro/core": "file:../../packages/core", - "@d3ro/i18n": "file:../../packages/i18n", - "@d3ro/ui-native": "file:../../packages/ui-native", - "@react-native-async-storage/async-storage": "1.23.1", - "@supabase/supabase-js": "^2.45.0", - "expo": "~51.0.0", - "expo-av": "~14.0.0", - "expo-constants": "~16.0.0", - "expo-device": "~6.0.0", - "expo-linking": "~6.3.0", - "expo-notifications": "~0.28.0", - "expo-router": "~3.5.0", - "expo-secure-store": "~13.0.0", - "expo-status-bar": "~1.12.0", - "expo-web-browser": "~13.0.0", - "react": "18.2.0", - "react-native": "0.74.0", - "react-native-gesture-handler": "~2.16.0", - "react-native-reanimated": "~3.10.0", - "react-native-safe-area-context": "4.10.0", - "react-native-screens": "3.31.0", - "react-native-url-polyfill": "^2.0.0" - }, - "devDependencies": { - "@types/react": "~18.2.0", - "typescript": "^5.7.0" - } - }, "apps/mobile-rn": { "name": "@d3ro/mobile-rn", "version": "1.1.0", diff --git a/package.json b/package.json index 124fa6a..4948d5a 100644 --- a/package.json +++ b/package.json @@ -22,8 +22,14 @@ "version:sync": "node scripts/ci/sync-version.mjs --write", "release:metadata": "node scripts/ci/verify-release-metadata.mjs", "release:metadata:test": "node scripts/ci/verify-release-metadata.mjs --self-test", + "release:forgejo": "node scripts/ci/publish-forgejo-release.mjs", + "release:forgejo:local": "node --env-file-if-exists=.env scripts/ci/publish-forgejo-release.mjs", + "release:forgejo:check": "node scripts/ci/publish-forgejo-release.mjs --check", + "release:tag": "node scripts/ci/create-release-tag.mjs", "security:secrets": "node scripts/ci/check-no-hardcoded-secrets.mjs", "security:secrets:test": "node scripts/ci/check-no-hardcoded-secrets.mjs --self-test", + "check:design": "node scripts/ci/check-design-tokens.mjs", + "check:design:test": "node scripts/ci/check-design-tokens.mjs --self-test", "test:e2e:red": "node server/supabase/tests/content-report-red.e2e.mjs", "release:mobile:boundary": "node scripts/ci/verify-mobile-release-boundary.mjs", "release:mobile:boundary:test": "node scripts/ci/verify-mobile-release-boundary.mjs --self-test", @@ -35,7 +41,11 @@ "typecheck": "npm run typecheck --workspaces --if-present", "test": "npm run test --workspaces --if-present", "lint": "eslint apps/desktop apps/web apps/admin packages --ext .ts,.tsx --max-warnings=0", - "format": "prettier --write \"**/*.{ts,tsx,css}\"" + "format": "prettier --write \"**/*.{ts,tsx,css}\"", + "typecheck:mobile": "npm --prefix apps/mobile-rn run typecheck", + "lint:mobile": "npm --prefix apps/mobile-rn run lint", + "test:mobile": "npm --prefix apps/mobile-rn test", + "verify:all": "npm run typecheck && npm run typecheck:mobile && npm run lint && npm run lint:mobile && npm run test && npm run test:mobile && npm run check:design" }, "devDependencies": { "@typescript-eslint/eslint-plugin": "^8.0.0", diff --git a/release/update-policy.json b/release/update-policy.json new file mode 100644 index 0000000..c937261 --- /dev/null +++ b/release/update-policy.json @@ -0,0 +1,15 @@ +{ + "schemaVersion": 1, + "defaultChannel": "latest", + "channels": { + "latest": { "allowPrerelease": false }, + "beta": { "allowPrerelease": true }, + "alpha": { "allowPrerelease": true } + }, + "minimumSupportedVersion": "1.0.0", + "forceInstallBelow": "0.3.0", + "fullInstallOnMajorChange": true, + "fullInstallVersionGap": 3, + "stagingPercentage": 100, + "killSwitch": false +} diff --git a/scripts/ci/check-design-tokens.mjs b/scripts/ci/check-design-tokens.mjs new file mode 100644 index 0000000..ba074e7 --- /dev/null +++ b/scripts/ci/check-design-tokens.mjs @@ -0,0 +1,208 @@ +// scripts/ci/check-design-tokens.mjs +// +// Design-token SSOT guard. Fails when a surface hardcodes a color, uses a +// numeric literal for spacing/radius/control that a token already owns, or +// re-introduces a bold weight that design.md v3 retired. +// +// The point is not zero-hex everywhere: a token *definition* file legitimately +// holds raw values. Everything else must consume --d3-* / d3ro* tokens. +// +// Usage: +// node scripts/ci/check-design-tokens.mjs # check, exit 1 on violation +// node scripts/ci/check-design-tokens.mjs --json # machine-readable report +// node scripts/ci/check-design-tokens.mjs --self-test +// +// Allowlisted paths are the ONLY places raw color literals may live. + +import { readdirSync, readFileSync, statSync } from 'node:fs' +import { dirname, join, relative, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const HERE = dirname(fileURLToPath(import.meta.url)) +const ROOT = resolve(HERE, '..', '..') + +// Directories scanned for consumer code (not token definitions). +const TARGETS = [ + 'apps/desktop/src', + 'apps/web/src', + 'apps/admin/src', + 'apps/mobile-rn/src', + 'packages/ui/src', + 'packages/ui-native/src', + 'site/src', + 'site/public', +] + +// Token *definition* layers. Raw values are the point here, not a violation. +const ALLOWLIST = new Set([ + 'packages/ui/src/theme.ts', + 'packages/ui/src/theme-vars.ts', + 'packages/ui-native/src/theme.ts', + 'apps/mobile-rn/src/theme/mobile-theme.ts', + 'apps/admin/src/lib/console-theme.ts', + // Canvas cannot resolve CSS custom properties; these are SSR fallbacks that + // mirror --d3-gradient-wave1..4 and are never painted on the client. + 'packages/ui/src/components/ds/GradientWave.tsx', + 'packages/ui/src/components/ds/AudioVisualizerBar.tsx', + 'apps/desktop/src/main/services/MeetingModeService.ts', + 'apps/desktop/src/main/services/CloudSyncService.ts', + 'apps/desktop/src/main/windows/WindowManager.ts', + 'site/src/tokens.ts', + 'site/src/index.css', + 'site/tailwind.config.js', + 'site/public/accept-invite.css', + 'site/public/legal.css', + 'apps/desktop/src/renderer/styles/global.css', +]) + +const IGNORED_DIRS = new Set([ + 'node_modules', '.next', 'dist', 'build', 'out', 'coverage', + '.turbo', 'android', 'ios', '__snapshots__', +]) + +const SCAN_EXT = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.css', '.html']) + +// A token definition file may define local `:root` fallbacks for popups. +const isPopupStyle = (rel) => /apps\/desktop\/src\/renderer\/popups\/.*\/style\.css$/.test(rel) +const isTestFile = (rel) => /\.(test|spec)\.(ts|tsx|js|jsx|mjs)$/.test(rel) + +const HEX = /#[0-9a-fA-F]{3,8}(?![0-9a-fA-F])/g +const FUNC_COLOR = /\b(?:rgba?|hsla?)\([^)]*\)/g +const BOLD_WEIGHT = /(font-?weight\s*[:=]\s*['"]?([7-9]\d0)\b|fontWeight\s*:\s*([7-9]\d0)\b)/g + +function walk(dir, files) { + let entries + try { + entries = readdirSync(dir) + } catch { + return + } + for (const name of entries) { + if (IGNORED_DIRS.has(name)) continue + const full = join(dir, name) + const st = statSync(full) + if (st.isDirectory()) walk(full, files) + else if (SCAN_EXT.has(name.slice(name.lastIndexOf('.')))) files.push(full) + } +} + +function isAllowlisted(rel) { + if (ALLOWLIST.has(rel)) return true + if (isPopupStyle(rel)) return true + if (isTestFile(rel)) return true + return false +} + +function hexLooksLikeColor(match, line, index) { + const before = line[index - 1] + // URL fragment / selector boundary: #features, #root, url(#clip) + if (before && /[A-Za-z0-9_\-/)&(]/.test(before)) return false + // HTML attribute value: href="#download", id='#x' + if ((before === '"' || before === "'") && line[index - 2] === '=') return false + return true +} + +// A mask gradient uses white as an opacity stencil, not a painted color. +const isMaskIdiom = (line) => /#fff 0 0/.test(line) +// Comment lines describe identifiers like #access_token; they are not colors. +const isCommentLine = (line) => /^\s*(\/\/|\*|\/\*|