release: ship v1.6.0 with paged suggestions and a cleaner phrase memory
Next-sentence suggestions now arrive one at a time up to twelve, shown three per page with Ctrl+Alt+Up/Down to move, Left/Right to page, Enter to accept and Esc to close; old default bindings migrate and the panel guide follows the live bindings. The overlay is redesigned, stays put while candidates stream and sits outside the input box when no caret is reported. The personal phrase memory stops learning from terminals, code editors and the coding-agent hub, ignores symbol-heavy lines and empty-field placeholders, and prunes existing entries that break those rules. Fixes suggestion keys starting dictation, installs stuck on a pre-1.5.0 speech engine without the focus endpoint, Ollama runner windows flashing while typing, the speech engine starting twice, and cold-model timeouts. Live captions can be dragged to a remembered position and show a waiting notice until the first line arrives. Bumps the product version to 1.6.0 (Android/iOS build 1060000).
This commit is contained in:
parent
856e375f3e
commit
2fe20fa7b5
71 changed files with 2469 additions and 366 deletions
|
|
@ -26,6 +26,16 @@ export interface UiRect {
|
|||
height: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 앵커 rect 가 케어렛(한 줄 위치)인지 포커스 요소 전체인지.
|
||||
*
|
||||
* 케어렛을 못 주는 제공자가 많아(Chrome, Windows Terminal 등 caret=-1) 그때는
|
||||
* elementRect 로 폴백하는데, 두 경우는 배치 전략이 달라야 한다 — 케어렛은 "그
|
||||
* 줄 아래" 에 붙이면 되지만, elementRect(멀티라인/큰 입력창 전체)는 그 규칙을
|
||||
* 그대로 쓰면 텍스트와 멀리 떨어지거나 텍스트 위에 겹친다.
|
||||
*/
|
||||
export type AnchorKind = 'caret' | 'element' | null
|
||||
|
||||
/** 마우스 이동 누적 전에 무시할 최소 이동량(px). 미세抖动 노이즈 제거. */
|
||||
export const MOUSE_MOVE_NOISE_FLOOR_PX = 2
|
||||
|
||||
|
|
@ -37,16 +47,24 @@ export function manhattanDistance(from: { x: number; y: number }, to: { x: numbe
|
|||
/**
|
||||
* 오버레이 커 좌표 계산.
|
||||
*
|
||||
* 앵커(케어렛/포커스 요소) 아래에 붙이는 것이 기본 — IME 후보창과 같은 관례다.
|
||||
* 아래 공간이 없으면 위로 뒤집고, 마지막으로 작업영역 안으로 클램프한다.
|
||||
* 케어렛 앵커(또는 앵커 없음 → 커서)는 앵커 아래에 붙이는 것이 기본 — IME 후보창과
|
||||
* 같은 관례다. 아래 공간이 없으면 위로 뒤집는다.
|
||||
* 요소 앵커(케어렛을 못 얻어 elementRect 로 폴백한 경우)는 요소 "바깥" 에 붙인다 —
|
||||
* 아래/위/오른쪽/왼쪽 순으로 작업영역에 들어맞는 첫 방향을 쓴다.
|
||||
* 마지막으로 항상 작업영역 안으로 클램프한다.
|
||||
*/
|
||||
export function anchorFloatingPanel(
|
||||
anchor: UiRect | null,
|
||||
anchorKind: AnchorKind,
|
||||
cursor: { x: number; y: number },
|
||||
size: { width: number; height: number },
|
||||
workArea: UiRect,
|
||||
gap = 6
|
||||
): { x: number; y: number } {
|
||||
if (anchorKind === 'element' && anchor && anchor.width >= 0 && anchor.height >= 0) {
|
||||
return anchorOutsideElement(anchor, size, workArea, gap)
|
||||
}
|
||||
|
||||
const rect: UiRect =
|
||||
anchor && anchor.width >= 0 && anchor.height >= 0
|
||||
? anchor
|
||||
|
|
@ -64,6 +82,56 @@ export function anchorFloatingPanel(
|
|||
}
|
||||
}
|
||||
|
||||
function fitsInWorkArea(
|
||||
x: number,
|
||||
y: number,
|
||||
size: { width: number; height: number },
|
||||
workArea: UiRect
|
||||
): boolean {
|
||||
return (
|
||||
x >= workArea.x &&
|
||||
y >= workArea.y &&
|
||||
x + size.width <= workArea.x + workArea.width &&
|
||||
y + size.height <= workArea.y + workArea.height
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 요소 앵커를 요소 "바깥" 에 배치한다.
|
||||
*
|
||||
* 케어렛을 못 얻어 elementRect(입력창 전체)로 폴백했을 때, 기존 "앵커 아래/위" 규칙을
|
||||
* 그대로 쓰면 큰/여러 줄 요소에서는 텍스트와 멀리 떨어지거나 텍스트 위에 겹친다
|
||||
* (실측: 2·3번째 생성에서 패널이 튐). 아래→위→오른쪽→왼쪽 순으로 작업영역에
|
||||
* 맞는 첫 방향을 쓰고, 전부 안 맞으면 요소 안쪽 우하단 모서리로 물러난다.
|
||||
*/
|
||||
function anchorOutsideElement(
|
||||
element: UiRect,
|
||||
size: { width: number; height: number },
|
||||
workArea: UiRect,
|
||||
gap: number
|
||||
): { x: number; y: number } {
|
||||
const candidates: Array<{ x: number; y: number }> = [
|
||||
{ x: element.x, y: element.y + element.height + gap }, // 아래
|
||||
{ x: element.x, y: element.y - gap - size.height }, // 위
|
||||
{ x: element.x + element.width + gap, y: element.y }, // 오른쪽
|
||||
{ x: element.x - gap - size.width, y: element.y } // 왼쪽
|
||||
]
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (fitsInWorkArea(candidate.x, candidate.y, size, workArea)) return candidate
|
||||
}
|
||||
|
||||
const fallback = {
|
||||
x: element.x + element.width - size.width - gap,
|
||||
y: element.y + element.height - size.height - gap
|
||||
}
|
||||
|
||||
return {
|
||||
x: clamp(fallback.x, workArea.x, workArea.x + workArea.width - size.width),
|
||||
y: clamp(fallback.y, workArea.y, workArea.y + workArea.height - size.height)
|
||||
}
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
if (max < min) return min
|
||||
return Math.max(min, Math.min(value, max))
|
||||
|
|
@ -401,6 +469,8 @@ export type SuggestionSkipReason =
|
|||
| 'unchanged'
|
||||
/** 연속 실패 후 쿨다운 중 (모델이 다른 작업으로 바쁠 수 있다) */
|
||||
| 'cooldown'
|
||||
/** 포커스만 옮겨 왔을 뿐 이 필드에서 실제로 타이핑하지 않았다 (마우스 클릭 등) */
|
||||
| 'not-typing'
|
||||
|
||||
export type SuggestionDecision =
|
||||
| { action: 'request'; prefix: string }
|
||||
|
|
@ -419,6 +489,10 @@ export interface SuggestionPolicyInput {
|
|||
isEditable: boolean
|
||||
appName: string | null
|
||||
excludedApps: readonly string[]
|
||||
/** 포커스가 바뀐 뒤 이 필드에서 실제로 편집이 있었는가 (마우스로 필드에 들어오기만 한 경우 false) */
|
||||
editedSinceFocus: boolean
|
||||
/** 최근에 실제로 타이핑했는가 (recentTypingWindowMs 이내) */
|
||||
typedRecently: boolean
|
||||
/** 어렛 앞 스트 */
|
||||
prefix: string
|
||||
idleMs: number
|
||||
|
|
@ -446,6 +520,14 @@ export function decideSuggestion(input: SuggestionPolicyInput): SuggestionDecisi
|
|||
if (input.appName && isAppExcluded(input.appName, input.excludedApps)) {
|
||||
return { action: 'clear', reason: 'excluded-app' }
|
||||
}
|
||||
// 포커스만 옮겨 왔을 뿐(마우스 클릭 등) 이 필드에서 아무것도 치지 않았으면 제안하지 않는다.
|
||||
//
|
||||
// 유휴 판정이 키보드 기준이라, 필드에 이미 차 있던 텍스트로 클릭만 해도 (마지막
|
||||
// 키 입력이 오래전이라) idleMs 조건을 통과해 제안이 뜨던 문제(실측: YouTube 검색창
|
||||
// 클릭만 했는데 옛 검색어로 제안이 뜸).
|
||||
if (!input.editedSinceFocus || !input.typedRecently) {
|
||||
return { action: 'clear', reason: 'not-typing' }
|
||||
}
|
||||
|
||||
const prefix = input.prefix.replace(/\s+$/u, '')
|
||||
if (!prefix) return { action: 'clear', reason: 'empty-prefix' }
|
||||
|
|
@ -481,6 +563,34 @@ export function decideSuggestion(input: SuggestionPolicyInput): SuggestionDecisi
|
|||
return { action: 'request', prefix }
|
||||
}
|
||||
|
||||
/**
|
||||
* 생성 접두가 현재 접두의 연속 확장인지 판정한다.
|
||||
*
|
||||
* 마지막 글자는 아직 조합 중인 IME 음절일 수 있어, 그 한 글자를 뺀 접두까지도
|
||||
* 연속 확장으로 인정한다 (예: 생성 시점 "하" → 현재 "한").
|
||||
*/
|
||||
export function extendsPrefix(generatedPrefix: string, currentPrefix: string): boolean {
|
||||
const generated = generatedPrefix.replace(/\s+$/u, '')
|
||||
const current = currentPrefix.replace(/\s+$/u, '')
|
||||
if (current.startsWith(generated)) return true
|
||||
return generated.length > 0 && current.startsWith(generated.slice(0, -1))
|
||||
}
|
||||
|
||||
/**
|
||||
* 표시 중인 제안 세션(페이지 넘기며 보는 후보 목록)이 여전히 이 접두에 유효한지.
|
||||
*
|
||||
* `extendsPrefix` 와 달리 성장(이어 치기)은 허용하지 않는다 — "다음 문장이 시작됐다"
|
||||
* 는 곧 세션 종료다(설계). 마지막 글자만 IME 조합으로 바뀐 경우만 예외로 둔다
|
||||
* (생성 시점 "하" → 지금 "한": 길이는 같고 마지막 글자만 다르다).
|
||||
*/
|
||||
export function matchesSessionPrefix(generatedPrefix: string, currentPrefix: string): boolean {
|
||||
const generated = generatedPrefix.replace(/\s+$/u, '')
|
||||
const current = currentPrefix.replace(/\s+$/u, '')
|
||||
if (current === generated) return true
|
||||
if (generated.length === 0) return false
|
||||
return current.length === generated.length && current.slice(0, -1) === generated.slice(0, -1)
|
||||
}
|
||||
|
||||
/**
|
||||
* 표시 중인 제안을 계속 둘지, 새로 만들지, 즉시 버릴지 결정한다.
|
||||
*
|
||||
|
|
@ -496,7 +606,7 @@ export function decideSuggestionRefresh(
|
|||
const generated = generatedPrefix.replace(/\s+$/u, '')
|
||||
const current = currentPrefix.replace(/\s+$/u, '')
|
||||
if (!generated) return 'regenerate'
|
||||
if (!current.startsWith(generated)) return 'stale'
|
||||
if (!extendsPrefix(generated, current)) return 'stale'
|
||||
return current.length - generated.length >= minimumGrowth ? 'regenerate' : 'keep'
|
||||
}
|
||||
|
||||
|
|
@ -513,6 +623,84 @@ export function isAppExcluded(appName: string, excludedApps: readonly string[]):
|
|||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 터미널 — 화면 버퍼가 곧 "입력창" 으로 읽혀 상태줄·명령·출력이 친 글로 잡힌다
|
||||
* (실측: Claude Code 상태줄 `◑ OPUS 5`, `5 medium │ CTX ▕░░▏` 가 학습됨).
|
||||
* 제안도 셸 프롬프트 위에 뜨므로 여기서는 제안과 학습을 모두 하지 않는다.
|
||||
*/
|
||||
export const TERMINAL_APPS: readonly string[] = Object.freeze([
|
||||
'WindowsTerminal',
|
||||
'wt',
|
||||
'OpenConsole',
|
||||
'conhost',
|
||||
'cmd',
|
||||
'powershell',
|
||||
'pwsh',
|
||||
'mintty',
|
||||
'alacritty',
|
||||
'wezterm-gui',
|
||||
'Hyper',
|
||||
'Tabby',
|
||||
'Warp'
|
||||
])
|
||||
|
||||
/**
|
||||
* 학습에서 빼는 앱 — 터미널 + 코드 에디터 + 코딩 에이전트 허브.
|
||||
*
|
||||
* 개인 문구 코퍼스는 사용자의 자연어 문체를 배우는 곳이다. 코드와 에이전트에게
|
||||
* 보낸 개발 지시가 섞이면 카카오톡에서도 개발 문장이 제안된다(실측: 코퍼스 138개 중
|
||||
* 대부분이 터미널·Agent Switchboard 발). 제안 자체는 에디터/에이전트에서도 허용한다.
|
||||
*/
|
||||
export const LEARNING_EXCLUDED_APPS: readonly string[] = Object.freeze([
|
||||
...TERMINAL_APPS,
|
||||
'Code',
|
||||
'Code - Insiders',
|
||||
'Cursor',
|
||||
'Windsurf',
|
||||
'Antigravity',
|
||||
'Zed',
|
||||
'devenv',
|
||||
'idea64',
|
||||
'pycharm64',
|
||||
'webstorm64',
|
||||
'rider64',
|
||||
'clion64',
|
||||
'goland64',
|
||||
'studio64',
|
||||
'sublime_text',
|
||||
'Agent Switchboard'
|
||||
])
|
||||
|
||||
/** 상자·블록·도형·기타 기호·딩뱃 — 터미널 UI/상태줄의 지문이다. */
|
||||
const NON_PROSE_GLYPH_PATTERN = /[←-⇿─-➿⬀-⯿]/u
|
||||
/** 공백을 뺀 글자 중 문자(모든 언어)가 이 비율 이상이어야 문장으로 본다. */
|
||||
const MIN_LETTER_RATIO = 0.6
|
||||
|
||||
/**
|
||||
* 개인 코퍼스에 넣어도 되는 문장인가.
|
||||
*
|
||||
* 문장이 아닌 것(타임스탬프 `5 분 5`, 상태줄 `00 ◷9`, 박스 선)을 거른다.
|
||||
* 앱 단위 제외(LEARNING_EXCLUDED_APPS)와 별개로 모든 출처에 적용한다.
|
||||
*/
|
||||
export function isLearnablePhrase(text: string): boolean {
|
||||
if (NON_PROSE_GLYPH_PATTERN.test(text)) return false
|
||||
const compact = text.replace(/\s+/gu, '')
|
||||
if (compact.length < 2) return false
|
||||
const letters = compact.match(/\p{L}/gu)?.length ?? 0
|
||||
return letters / compact.length >= MIN_LETTER_RATIO
|
||||
}
|
||||
|
||||
/**
|
||||
* 빈 입력창의 안내 문구(placeholder)를 텍스트로 돌려주는 제공자가 있다
|
||||
* (실측: KakaoTalk "메시지 입력", ChatGPT "ChatGPT에 메시지 보내기" 가 친 글로 학습됨).
|
||||
* 텍스트가 컨트롤 이름과 같으면 빈 칸으로 취급한다.
|
||||
*/
|
||||
export function withoutPlaceholderText(snapshot: FocusSnapshot): FocusSnapshot {
|
||||
const name = snapshot.controlName?.trim()
|
||||
if (!name || snapshot.text.trim() !== name) return snapshot
|
||||
return { ...snapshot, text: '', caretOffset: null }
|
||||
}
|
||||
|
||||
/**
|
||||
* 프롬프트에 넣을 컨텍스트 길이 상한.
|
||||
*
|
||||
|
|
@ -986,8 +1174,16 @@ export interface SuggestionState {
|
|||
partialText: string | null
|
||||
candidates: SuggestionCandidate[]
|
||||
activeIndex: number
|
||||
/**
|
||||
* 이 세션이 채우려는 후보 총량 — 모델 세션은 maxCandidatesTotal(12),
|
||||
* 로컬 기억 세션은 더 생성되지 않으므로 현재 candidates 수와 같다.
|
||||
* UI 가 "4–6 / 9" 같은 진행률을 보여주는 근거.
|
||||
*/
|
||||
targetTotal: number
|
||||
/** 앵커 rect (케어렛 → 요소 → 마우스 폴백은 메인이 계산) */
|
||||
anchor: UiRect | null
|
||||
/** 앵커가 케어렛인지 요소 전체인지 — 배치 전략(caret vs element)을 결정한다 */
|
||||
anchorKind: AnchorKind
|
||||
appName: string | null
|
||||
updatedAt: number
|
||||
lastSkipReason: SuggestionSkipReason | null
|
||||
|
|
@ -1031,7 +1227,16 @@ export const SUGGESTION_DEFAULTS = {
|
|||
minIntervalMs: 5000,
|
||||
maxRequestsPerMinute: 6,
|
||||
dailyBudget: 500,
|
||||
/** 로컬 기억 경로(폴백)에서 한 번에 만드는 후보 수 — 채우기 루프가 없다. */
|
||||
maxCandidates: 3,
|
||||
/**
|
||||
* 한 세션(모델 경로)이 채우기 루프로 쌓을 수 있는 후보 총량.
|
||||
*
|
||||
* 한 번에 요청하면 느리다(사용자 요청) — 1개씩 순차 요청해 채운다.
|
||||
*/
|
||||
maxCandidatesTotal: 12,
|
||||
/** 오버레이 한 페이지에 보여줄 후보 수. */
|
||||
pageSize: 3,
|
||||
maxOutputTokens: 64,
|
||||
/** 표시된 제안의 연속 접두가 이만큼 자랐을 때만 재생성한다. */
|
||||
regenerateAfterChars: 12,
|
||||
|
|
@ -1066,7 +1271,15 @@ export const SUGGESTION_DEFAULTS = {
|
|||
/** 연속 실패가 이 횟수에 도달하면 잠시 요청을 멈춘다 */
|
||||
failureCooldownThreshold: 2,
|
||||
/** 쿨다운 시간 (ms) */
|
||||
failureCooldownMs: 60000
|
||||
failureCooldownMs: 60000,
|
||||
/**
|
||||
* 이 시간 안에 실제 타이핑(letter/digit/symbol/space/backspace/delete/ime)이
|
||||
* 있어야 "지금 타이핑 중" 으로 본다.
|
||||
*
|
||||
* 마우스로 필드에 들어오기만 해도 (마지막 키 입력은 오래전이라) 유휴 조건을
|
||||
* 통과해 필드에 이미 있던 텍스트로 제안이 뜨던 문제를 막는다.
|
||||
*/
|
||||
recentTypingWindowMs: 8000
|
||||
} as const
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -472,6 +472,11 @@ export const IPC_CHANNELS = {
|
|||
// ── Popup Internal Channels (Caption) ──
|
||||
POPUP_CAPTION: {
|
||||
HIDE: 'caption:hide',
|
||||
/** 손잡이 위에 있는 동안만 마우스를 받는다 (그 외에는 클릭 통과) */
|
||||
SET_INTERACTIVE: 'captionPopup:setInteractive',
|
||||
DRAG_START: 'captionPopup:dragStart',
|
||||
DRAG_END: 'captionPopup:dragEnd',
|
||||
RESET_POSITION: 'captionPopup:resetPosition',
|
||||
},
|
||||
|
||||
// ── Voice Partial Transcript (RecordingTip) ──
|
||||
|
|
|
|||
|
|
@ -681,6 +681,8 @@ export type KeyBindingActionId =
|
|||
| 'suggestion-next'
|
||||
| 'suggestion-prev'
|
||||
| 'suggestion-dismiss'
|
||||
| 'suggestion-page-next'
|
||||
| 'suggestion-page-prev'
|
||||
|
||||
/** 액션 그룹 (설정 화면 섹션) */
|
||||
export type KeyBindingActionGroup = 'voice' | 'window' | 'input'
|
||||
|
|
@ -700,7 +702,8 @@ export interface KeyBindingActionSpec {
|
|||
defaultBindings: readonly KeyBinding[]
|
||||
}
|
||||
|
||||
function kb(
|
||||
/** 키보드 바인딩을 간결하게 만든다. 기본값(옛 기본값 등)을 구성할 때 이 모듈 밖에서도 쓴다. */
|
||||
export function kb(
|
||||
code: number,
|
||||
mods: Partial<Pick<KeyBinding, 'ctrl' | 'alt' | 'shift' | 'meta'>> = {}
|
||||
): KeyBinding {
|
||||
|
|
@ -783,9 +786,9 @@ export const KEYBINDING_ACTIONS: readonly KeyBindingActionSpec[] = Object.freeze
|
|||
descriptionKey: 'keybinding.action.suggestionAccept.desc',
|
||||
holdMode: false,
|
||||
doublePress: false,
|
||||
// 사용자 요청으로 Ctrl+Alt+화살표 계열로 통일했다:
|
||||
// 오른쪽 수락 / 아래 다음 후보 / 위 이전 후보 / 왼쪽 닫기.
|
||||
defaultBindings: [kb(VK.ArrowRight, { ctrl: true, alt: true })]
|
||||
// Ctrl+Alt+화살표 계열: 아래/위로 후보 순환, 좌우로 페이지 이동(12개까지
|
||||
// 순차 생성 — 사용자 요청). 화살표를 페이지 이동에 내주기 위해 수락은 Enter로 옮겼다.
|
||||
defaultBindings: [kb(VK.Enter, { ctrl: true, alt: true })]
|
||||
},
|
||||
{
|
||||
id: 'suggestion-next',
|
||||
|
|
@ -805,6 +808,24 @@ export const KEYBINDING_ACTIONS: readonly KeyBindingActionSpec[] = Object.freeze
|
|||
doublePress: false,
|
||||
defaultBindings: [kb(VK.ArrowUp, { ctrl: true, alt: true })]
|
||||
},
|
||||
{
|
||||
id: 'suggestion-page-next',
|
||||
group: 'input',
|
||||
labelKey: 'keybinding.action.suggestionPageNext',
|
||||
descriptionKey: 'keybinding.action.suggestionPageNext.desc',
|
||||
holdMode: false,
|
||||
doublePress: false,
|
||||
defaultBindings: [kb(VK.ArrowRight, { ctrl: true, alt: true })]
|
||||
},
|
||||
{
|
||||
id: 'suggestion-page-prev',
|
||||
group: 'input',
|
||||
labelKey: 'keybinding.action.suggestionPagePrev',
|
||||
descriptionKey: 'keybinding.action.suggestionPagePrev.desc',
|
||||
holdMode: false,
|
||||
doublePress: false,
|
||||
defaultBindings: [kb(VK.ArrowLeft, { ctrl: true, alt: true })]
|
||||
},
|
||||
{
|
||||
id: 'suggestion-dismiss',
|
||||
group: 'input',
|
||||
|
|
@ -812,7 +833,11 @@ export const KEYBINDING_ACTIONS: readonly KeyBindingActionSpec[] = Object.freeze
|
|||
descriptionKey: 'keybinding.action.suggestionDismiss.desc',
|
||||
holdMode: false,
|
||||
doublePress: false,
|
||||
defaultBindings: [kb(VK.ArrowLeft, { ctrl: true, alt: true })]
|
||||
// "모든 액션은 기본 바인딩을 하나 이상 갖는다" 가 카탈로그 불변식이라
|
||||
// (KEYBINDING_ACTIONS 불변식 테스트) 빈 배열을 기본값으로 두지 않는다.
|
||||
// 평범한 Esc(전역, 수정자 없음)가 오버레이가 떠 있을 때만 반응하는
|
||||
// 별도 경로로 항상 닫아 주므로, 이 바인딩은 보조 수단이다.
|
||||
defaultBindings: [kb(VK.Backspace, { ctrl: true, alt: true })]
|
||||
}
|
||||
])
|
||||
|
||||
|
|
|
|||
|
|
@ -495,6 +495,8 @@ export interface AppConfig {
|
|||
activeChainId: string | null
|
||||
/** Phase 10.1: Caption audio source (CaptionService, MeetingModeService) */
|
||||
captionAudioSource: import('@d3ro/core/types').CaptionAudioSource
|
||||
/** 사용자가 끌어다 놓은 자막 창 위치 (스크린 좌표). null 이면 화면 아래 가운데 */
|
||||
captionOverlayPosition: { x: number; y: number } | null
|
||||
/** Auto-update 채널 (latest=stable / beta / alpha). UpdateService */
|
||||
updateChannel: 'latest' | 'beta' | 'alpha'
|
||||
/** staged rollout용 영구 기기 식별자 (개인정보 아님, 최초 실행 시 생성) */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue