@anionex/dsh-eye-care 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.i18n.yaml +6 -0
- package/README.md +86 -0
- package/README.zh.md +86 -0
- package/assets/eye-care-dark.png +0 -0
- package/assets/eye-care-light.png +0 -0
- package/cordis.patch.yml +3 -0
- package/lib/client.js +1106 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +14 -0
- package/lib/index.js.map +1 -0
- package/lib/rpc.js +123 -0
- package/lib/rpc.js.map +1 -0
- package/lib/settings.js +12 -0
- package/lib/settings.js.map +1 -0
- package/lib/shared.js +70 -0
- package/lib/shared.js.map +1 -0
- package/lib/types/client/EyeCareRow.d.ts +20 -0
- package/lib/types/client/EyeCareRow.d.ts.map +1 -0
- package/lib/types/client/controller.d.ts +96 -0
- package/lib/types/client/controller.d.ts.map +1 -0
- package/lib/types/client/index.d.ts +23 -0
- package/lib/types/client/index.d.ts.map +1 -0
- package/lib/types/client/locales.d.ts +54 -0
- package/lib/types/client/locales.d.ts.map +1 -0
- package/lib/types/client/themes.d.ts +14 -0
- package/lib/types/client/themes.d.ts.map +1 -0
- package/lib/types/index.d.ts +10 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/rpc.d.ts +17 -0
- package/lib/types/rpc.d.ts.map +1 -0
- package/lib/types/settings.d.ts +8 -0
- package/lib/types/settings.d.ts.map +1 -0
- package/lib/types/shared.d.ts +59 -0
- package/lib/types/shared.d.ts.map +1 -0
- package/package.json +99 -0
- package/src/client/EyeCareRow.module.css +137 -0
- package/src/client/EyeCareRow.tsx +122 -0
- package/src/client/controller.ts +435 -0
- package/src/client/css-modules.d.ts +5 -0
- package/src/client/index.tsx +72 -0
- package/src/client/locales.ts +56 -0
- package/src/client/themes.ts +254 -0
- package/src/index.ts +30 -0
- package/src/rpc.ts +138 -0
- package/src/settings.ts +22 -0
- package/src/shared.ts +112 -0
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
/** Eye-care state owner: durable settings synchronization plus theme lifecycle. */
|
|
2
|
+
|
|
3
|
+
import type { ClientConnectionRpc } from '@deepseek-ai/dsh-client-connection/client'
|
|
4
|
+
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
|
5
|
+
import type { ThemeRuntime, ThemeSnapshot } from '@deepseek-ai/dsh-client-ui-theme/client'
|
|
6
|
+
import {
|
|
7
|
+
DEFAULT_EYE_CARE_SETTINGS,
|
|
8
|
+
decodeEyeCareSnapshot,
|
|
9
|
+
EYE_CARE_RPC_CHANNEL,
|
|
10
|
+
EYE_CARE_RPC_READ,
|
|
11
|
+
EYE_CARE_RPC_SAVE,
|
|
12
|
+
type EyeCareIntensity,
|
|
13
|
+
type EyeCareMode,
|
|
14
|
+
type EyeCareSettings,
|
|
15
|
+
type EyeCareSettingsSnapshot,
|
|
16
|
+
} from '../shared.ts'
|
|
17
|
+
import {
|
|
18
|
+
EYE_CARE_THEMES,
|
|
19
|
+
eyeCareThemeId,
|
|
20
|
+
isEyeCareThemeId,
|
|
21
|
+
} from './themes.ts'
|
|
22
|
+
|
|
23
|
+
/** State consumed by the General-settings row. */
|
|
24
|
+
export interface EyeCareState {
|
|
25
|
+
/** Current eye-care selection. */
|
|
26
|
+
settings: EyeCareSettings
|
|
27
|
+
/** Persistence lifecycle. */
|
|
28
|
+
status: 'loading' | 'ready' | 'saving' | 'unavailable' | 'error'
|
|
29
|
+
/** Whether the Host settings document can be written. */
|
|
30
|
+
writable: boolean
|
|
31
|
+
/** Host namespace revision for the next write. */
|
|
32
|
+
revision: number | undefined
|
|
33
|
+
/** User-facing read or write failure, when one stands. */
|
|
34
|
+
error: string | null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Inputs isolated for deterministic controller tests. */
|
|
38
|
+
export interface EyeCareControllerOptions {
|
|
39
|
+
/** DSH theme registry. */
|
|
40
|
+
theme: Pick<ThemeRuntime, 'getTheme' | 'setTheme' | 'register'>
|
|
41
|
+
/** Snapshot change subscription. */
|
|
42
|
+
subscribeTheme(listener: (snapshot: ThemeSnapshot) => void): () => void
|
|
43
|
+
/** Loopback-only Connection RPC; absent remote browsers stay process-local. */
|
|
44
|
+
rpc?: ClientConnectionRpc
|
|
45
|
+
/** System dark-mode sensor. */
|
|
46
|
+
media?: MediaQueryList
|
|
47
|
+
/** Monotonic wall-clock source used only by the one-shot startup adoption fence. */
|
|
48
|
+
now?: () => number
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const INITIAL_STATE: EyeCareState = {
|
|
52
|
+
settings: { ...DEFAULT_EYE_CARE_SETTINGS },
|
|
53
|
+
status: 'loading',
|
|
54
|
+
writable: false,
|
|
55
|
+
revision: undefined,
|
|
56
|
+
error: null,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Maximum delay accepted for ui-theme's already-started Host adoption. */
|
|
60
|
+
const STARTUP_BASE_THEME_GRACE_MS = 1000
|
|
61
|
+
|
|
62
|
+
function sameSettings(left: EyeCareSettings, right: EyeCareSettings): boolean {
|
|
63
|
+
return left.mode === right.mode && left.intensity === right.intensity
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function messageOf(error: unknown): string {
|
|
67
|
+
return error instanceof Error ? error.message : String(error)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Stateful controller shared by theme listeners and the Settings row. */
|
|
71
|
+
export class EyeCareController {
|
|
72
|
+
/** Reactive row source. */
|
|
73
|
+
readonly store: SnapshotStore<EyeCareState> = createSnapshotStore(INITIAL_STATE)
|
|
74
|
+
|
|
75
|
+
private readonly theme: EyeCareControllerOptions['theme']
|
|
76
|
+
private readonly rpc: ClientConnectionRpc | undefined
|
|
77
|
+
private readonly media: MediaQueryList | undefined
|
|
78
|
+
private readonly now: () => number
|
|
79
|
+
private readonly unregisterThemes: Array<() => void> = []
|
|
80
|
+
private unsubscribeTheme: (() => void) | undefined
|
|
81
|
+
private unsubscribeMedia: (() => void) | undefined
|
|
82
|
+
private tail: Promise<void> = Promise.resolve()
|
|
83
|
+
private disposeTask: Promise<void> | undefined
|
|
84
|
+
private pendingSettings: EyeCareSettings | undefined
|
|
85
|
+
private disposed = false
|
|
86
|
+
private applyingTheme = 0
|
|
87
|
+
private restoreTheme = 'system'
|
|
88
|
+
private hasRestoreTheme = false
|
|
89
|
+
private acceptedRemoteSnapshot = false
|
|
90
|
+
private startupBaseTheme: 'light' | 'dark' | 'system' | undefined
|
|
91
|
+
private startupBaseThemeDeadline = 0
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Register the concrete themes and lifecycle listeners.
|
|
95
|
+
* @param options - theme, transport, and system-scheme collaborators.
|
|
96
|
+
*/
|
|
97
|
+
constructor(options: EyeCareControllerOptions) {
|
|
98
|
+
this.theme = options.theme
|
|
99
|
+
this.rpc = options.rpc
|
|
100
|
+
this.now = options.now ?? Date.now
|
|
101
|
+
this.media = options.media ?? (typeof matchMedia === 'function' ? matchMedia('(prefers-color-scheme: dark)') : undefined)
|
|
102
|
+
const currentTheme = this.theme.getTheme().preference
|
|
103
|
+
if (!isEyeCareThemeId(currentTheme)) {
|
|
104
|
+
this.restoreTheme = currentTheme
|
|
105
|
+
this.hasRestoreTheme = true
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
for (const definition of EYE_CARE_THEMES) {
|
|
109
|
+
this.unregisterThemes.push(this.theme.register(definition))
|
|
110
|
+
}
|
|
111
|
+
this.unsubscribeTheme = options.subscribeTheme(snapshot => { this.onThemeChanged(snapshot) })
|
|
112
|
+
if (this.media !== undefined) {
|
|
113
|
+
const media = this.media
|
|
114
|
+
const listener = (): void => {
|
|
115
|
+
if (!this.disposed && this.store.getSnapshot().settings.mode === 'auto') this.applyCurrentTheme()
|
|
116
|
+
}
|
|
117
|
+
media.addEventListener('change', listener)
|
|
118
|
+
this.unsubscribeMedia = () => { media.removeEventListener('change', listener) }
|
|
119
|
+
}
|
|
120
|
+
} catch (error) {
|
|
121
|
+
for (const unregister of this.unregisterThemes.splice(0).reverse()) unregister()
|
|
122
|
+
throw error
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Load durable settings without blocking plugin activation.
|
|
128
|
+
* @returns settlement after this read reaches the serialized transport queue.
|
|
129
|
+
*/
|
|
130
|
+
load(): Promise<void> {
|
|
131
|
+
if (this.rpc === undefined) {
|
|
132
|
+
this.setUnavailable()
|
|
133
|
+
return Promise.resolve()
|
|
134
|
+
}
|
|
135
|
+
if (this.store.getSnapshot().revision === undefined) {
|
|
136
|
+
this.store.update(state => {
|
|
137
|
+
state.status = 'loading'
|
|
138
|
+
state.error = null
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
return this.enqueue(async () => { await this.readRemote() })
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Select a mode and persist the latest selection when Host settings are writable.
|
|
146
|
+
* @param mode - next operating mode.
|
|
147
|
+
* @returns settlement after the serialized persistence attempt.
|
|
148
|
+
*/
|
|
149
|
+
setMode(mode: EyeCareMode): Promise<void> {
|
|
150
|
+
return this.select({ ...this.store.getSnapshot().settings, mode })
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Select a warmth and persist the latest selection when Host settings are writable.
|
|
155
|
+
* @param intensity - next warmth level.
|
|
156
|
+
* @returns settlement after the serialized persistence attempt.
|
|
157
|
+
*/
|
|
158
|
+
setIntensity(intensity: EyeCareIntensity): Promise<void> {
|
|
159
|
+
return this.select({ ...this.store.getSnapshot().settings, intensity })
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Queue a refresh after a Host settings invalidation. */
|
|
163
|
+
refresh(): void {
|
|
164
|
+
void this.load()
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Reach transport quiescence, restore the user's live non-eye-care theme, and unregister all resources.
|
|
169
|
+
* @returns settlement after cleanup completes.
|
|
170
|
+
*/
|
|
171
|
+
dispose(): Promise<void> {
|
|
172
|
+
return this.disposeTask ??= this.disposeOnce()
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
private async disposeOnce(): Promise<void> {
|
|
176
|
+
this.disposed = true
|
|
177
|
+
this.pendingSettings = undefined
|
|
178
|
+
|
|
179
|
+
const failures: unknown[] = []
|
|
180
|
+
for (const unsubscribe of [this.unsubscribeMedia, this.unsubscribeTheme]) {
|
|
181
|
+
if (unsubscribe === undefined) continue
|
|
182
|
+
try { unsubscribe() } catch (error) { failures.push(error) }
|
|
183
|
+
}
|
|
184
|
+
this.unsubscribeMedia = undefined
|
|
185
|
+
this.unsubscribeTheme = undefined
|
|
186
|
+
|
|
187
|
+
await this.tail
|
|
188
|
+
|
|
189
|
+
const current = this.theme.getTheme()
|
|
190
|
+
if (isEyeCareThemeId(current.preference)) {
|
|
191
|
+
try {
|
|
192
|
+
this.setTheme(this.resolveRestoreTheme())
|
|
193
|
+
} catch (error) {
|
|
194
|
+
failures.push(error)
|
|
195
|
+
try { this.setTheme('system') } catch (fallbackError) { failures.push(fallbackError) }
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
for (const unregister of this.unregisterThemes.splice(0).reverse()) {
|
|
199
|
+
try { unregister() } catch (error) { failures.push(error) }
|
|
200
|
+
}
|
|
201
|
+
if (failures.length > 0) throw new AggregateError(failures, 'eye-care cleanup failed')
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
private enqueue(operation: () => Promise<void>): Promise<void> {
|
|
205
|
+
if (this.disposed) return Promise.resolve()
|
|
206
|
+
const task = this.tail.then(async () => {
|
|
207
|
+
if (!this.disposed) await operation()
|
|
208
|
+
})
|
|
209
|
+
this.tail = task.catch(() => {})
|
|
210
|
+
return task
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
private select(settings: EyeCareSettings): Promise<void> {
|
|
214
|
+
if (this.disposed) return Promise.resolve()
|
|
215
|
+
const previous = this.store.getSnapshot().settings
|
|
216
|
+
if (previous.mode === 'off' && settings.mode !== 'off') this.captureRestoreTheme()
|
|
217
|
+
this.store.update(state => {
|
|
218
|
+
state.settings = settings
|
|
219
|
+
state.error = null
|
|
220
|
+
if (state.status === 'error') state.status = state.revision === undefined ? 'unavailable' : 'ready'
|
|
221
|
+
})
|
|
222
|
+
this.applyCurrentTheme()
|
|
223
|
+
return this.persist(settings)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
private persist(settings: EyeCareSettings): Promise<void> {
|
|
227
|
+
const state = this.store.getSnapshot()
|
|
228
|
+
if (this.rpc === undefined) {
|
|
229
|
+
this.setUnavailable(state.error)
|
|
230
|
+
return Promise.resolve()
|
|
231
|
+
}
|
|
232
|
+
if (state.revision !== undefined && !state.writable) {
|
|
233
|
+
this.setUnavailable(state.error)
|
|
234
|
+
return Promise.resolve()
|
|
235
|
+
}
|
|
236
|
+
this.pendingSettings = { ...settings }
|
|
237
|
+
return this.enqueue(async () => { await this.flushPending() })
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
private async flushPending(): Promise<void> {
|
|
241
|
+
while (!this.disposed && this.pendingSettings !== undefined) {
|
|
242
|
+
const desired = this.pendingSettings
|
|
243
|
+
this.pendingSettings = undefined
|
|
244
|
+
|
|
245
|
+
let state = this.store.getSnapshot()
|
|
246
|
+
if (state.revision === undefined) {
|
|
247
|
+
if (this.pendingSettings === undefined) this.pendingSettings = desired
|
|
248
|
+
if (!await this.readRemote()) return
|
|
249
|
+
continue
|
|
250
|
+
}
|
|
251
|
+
if (!state.writable || state.revision === undefined) {
|
|
252
|
+
this.setUnavailable(state.error)
|
|
253
|
+
return
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
this.store.update(next => {
|
|
257
|
+
next.status = 'saving'
|
|
258
|
+
next.error = null
|
|
259
|
+
})
|
|
260
|
+
let response: Awaited<ReturnType<ClientConnectionRpc['call']>>
|
|
261
|
+
try {
|
|
262
|
+
const rpc = this.rpc
|
|
263
|
+
if (rpc === undefined) return
|
|
264
|
+
response = await rpc.call(EYE_CARE_RPC_CHANNEL, EYE_CARE_RPC_SAVE, {
|
|
265
|
+
expectedRevision: state.revision,
|
|
266
|
+
value: desired,
|
|
267
|
+
})
|
|
268
|
+
} catch (error) {
|
|
269
|
+
if (!this.disposed) this.setUnavailable(messageOf(error))
|
|
270
|
+
return
|
|
271
|
+
}
|
|
272
|
+
if (this.disposed) return
|
|
273
|
+
if (!response.ok) {
|
|
274
|
+
if (response.error.code === 'settings-conflict') {
|
|
275
|
+
const latest = this.pendingSettings ?? desired
|
|
276
|
+
this.pendingSettings = latest
|
|
277
|
+
if (!await this.readRemote()) return
|
|
278
|
+
if (this.disposed) return
|
|
279
|
+
continue
|
|
280
|
+
}
|
|
281
|
+
this.setUnavailable(response.error.message)
|
|
282
|
+
return
|
|
283
|
+
}
|
|
284
|
+
const snapshot = decodeEyeCareSnapshot(response.value)
|
|
285
|
+
if (snapshot === undefined) {
|
|
286
|
+
this.store.update(next => {
|
|
287
|
+
next.status = 'error'
|
|
288
|
+
next.error = 'eye-care Settings returned an invalid save response'
|
|
289
|
+
})
|
|
290
|
+
return
|
|
291
|
+
}
|
|
292
|
+
const preserveSettings = this.pendingSettings !== undefined
|
|
293
|
+
|| !sameSettings(this.store.getSnapshot().settings, desired)
|
|
294
|
+
this.accept(snapshot, preserveSettings)
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
private async readRemote(): Promise<boolean> {
|
|
299
|
+
let response: Awaited<ReturnType<ClientConnectionRpc['call']>>
|
|
300
|
+
try {
|
|
301
|
+
const rpc = this.rpc
|
|
302
|
+
if (rpc === undefined) return false
|
|
303
|
+
response = await rpc.call(EYE_CARE_RPC_CHANNEL, EYE_CARE_RPC_READ, {})
|
|
304
|
+
} catch (error) {
|
|
305
|
+
if (!this.disposed) this.setUnavailable(messageOf(error), true)
|
|
306
|
+
return false
|
|
307
|
+
}
|
|
308
|
+
if (this.disposed) return false
|
|
309
|
+
if (!response.ok) {
|
|
310
|
+
this.setUnavailable(response.error.message, true)
|
|
311
|
+
return false
|
|
312
|
+
}
|
|
313
|
+
const snapshot = decodeEyeCareSnapshot(response.value)
|
|
314
|
+
if (snapshot === undefined) {
|
|
315
|
+
this.store.update(state => {
|
|
316
|
+
state.status = 'error'
|
|
317
|
+
state.error = 'eye-care Settings returned an invalid read response'
|
|
318
|
+
})
|
|
319
|
+
return false
|
|
320
|
+
}
|
|
321
|
+
this.accept(snapshot, this.pendingSettings !== undefined)
|
|
322
|
+
return true
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
private accept(snapshot: EyeCareSettingsSnapshot, preserveSettings: boolean): void {
|
|
326
|
+
const previous = this.store.getSnapshot().settings
|
|
327
|
+
const next = preserveSettings ? previous : snapshot.settings.value
|
|
328
|
+
const firstRemote = !this.acceptedRemoteSnapshot
|
|
329
|
+
this.acceptedRemoteSnapshot = true
|
|
330
|
+
|
|
331
|
+
if (next.mode === 'off') {
|
|
332
|
+
this.startupBaseTheme = undefined
|
|
333
|
+
this.startupBaseThemeDeadline = 0
|
|
334
|
+
if (!this.hasRestoreTheme && snapshot.baseThemePreference !== undefined) {
|
|
335
|
+
this.restoreTheme = snapshot.baseThemePreference
|
|
336
|
+
this.hasRestoreTheme = true
|
|
337
|
+
}
|
|
338
|
+
} else if (firstRemote) {
|
|
339
|
+
const base = snapshot.baseThemePreference
|
|
340
|
+
if (base === undefined) {
|
|
341
|
+
if (!this.hasRestoreTheme) this.captureRestoreTheme()
|
|
342
|
+
} else {
|
|
343
|
+
this.restoreTheme = base
|
|
344
|
+
this.hasRestoreTheme = true
|
|
345
|
+
if (this.theme.getTheme().preference !== base) {
|
|
346
|
+
this.startupBaseTheme = base
|
|
347
|
+
this.startupBaseThemeDeadline = this.now() + STARTUP_BASE_THEME_GRACE_MS
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
} else if (!preserveSettings && previous.mode === 'off') {
|
|
351
|
+
this.captureRestoreTheme(snapshot.baseThemePreference)
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
this.store.set({
|
|
355
|
+
settings: next,
|
|
356
|
+
status: snapshot.writable ? 'ready' : 'unavailable',
|
|
357
|
+
writable: snapshot.writable,
|
|
358
|
+
revision: snapshot.settings.revision,
|
|
359
|
+
error: null,
|
|
360
|
+
})
|
|
361
|
+
this.applyCurrentTheme()
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
private setUnavailable(error: string | null = null, clearRevision = false): void {
|
|
365
|
+
this.store.update(state => {
|
|
366
|
+
state.status = 'unavailable'
|
|
367
|
+
state.writable = false
|
|
368
|
+
if (clearRevision) state.revision = undefined
|
|
369
|
+
state.error = error
|
|
370
|
+
})
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
private captureRestoreTheme(fallback?: 'light' | 'dark' | 'system'): void {
|
|
374
|
+
const preference = this.theme.getTheme().preference
|
|
375
|
+
const candidate = isEyeCareThemeId(preference) ? fallback ?? 'system' : preference
|
|
376
|
+
this.restoreTheme = candidate
|
|
377
|
+
this.hasRestoreTheme = true
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
private resolveRestoreTheme(): string {
|
|
381
|
+
if (!this.hasRestoreTheme || this.restoreTheme === 'system') return 'system'
|
|
382
|
+
return this.theme.getTheme().themes.some(theme => theme.id === this.restoreTheme)
|
|
383
|
+
? this.restoreTheme
|
|
384
|
+
: 'system'
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
private applyCurrentTheme(): void {
|
|
388
|
+
const { mode, intensity } = this.store.getSnapshot().settings
|
|
389
|
+
const current = this.theme.getTheme().preference
|
|
390
|
+
if (mode === 'off') {
|
|
391
|
+
if (isEyeCareThemeId(current)) this.setTheme(this.resolveRestoreTheme())
|
|
392
|
+
this.hasRestoreTheme = false
|
|
393
|
+
return
|
|
394
|
+
}
|
|
395
|
+
if (!this.hasRestoreTheme) this.captureRestoreTheme()
|
|
396
|
+
const scheme = mode === 'auto' ? (this.media?.matches === true ? 'dark' : 'light') : mode
|
|
397
|
+
this.setTheme(eyeCareThemeId(scheme, intensity))
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
private setTheme(id: string): void {
|
|
401
|
+
if (this.theme.getTheme().preference === id) return
|
|
402
|
+
this.applyingTheme += 1
|
|
403
|
+
try {
|
|
404
|
+
this.theme.setTheme(id)
|
|
405
|
+
} finally {
|
|
406
|
+
this.applyingTheme -= 1
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
private onThemeChanged(snapshot: ThemeSnapshot): void {
|
|
411
|
+
if (this.disposed || this.applyingTheme > 0 || isEyeCareThemeId(snapshot.preference)) return
|
|
412
|
+
if (this.startupBaseTheme !== undefined) {
|
|
413
|
+
const expected = this.startupBaseTheme
|
|
414
|
+
const withinStartupWindow = this.now() <= this.startupBaseThemeDeadline
|
|
415
|
+
this.startupBaseTheme = undefined
|
|
416
|
+
this.startupBaseThemeDeadline = 0
|
|
417
|
+
if (withinStartupWindow && snapshot.preference === expected) {
|
|
418
|
+
this.restoreTheme = expected
|
|
419
|
+
this.hasRestoreTheme = true
|
|
420
|
+
this.applyCurrentTheme()
|
|
421
|
+
return
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
const state = this.store.getSnapshot()
|
|
425
|
+
if (state.settings.mode === 'off') return
|
|
426
|
+
this.restoreTheme = snapshot.preference
|
|
427
|
+
this.hasRestoreTheme = true
|
|
428
|
+
const settings = { ...state.settings, mode: 'off' as const }
|
|
429
|
+
this.store.update(next => {
|
|
430
|
+
next.settings = settings
|
|
431
|
+
next.error = null
|
|
432
|
+
})
|
|
433
|
+
void this.persist(settings)
|
|
434
|
+
}
|
|
435
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/** DSH Web browser half for the eye-care profile bundle. */
|
|
2
|
+
|
|
3
|
+
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
|
|
4
|
+
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
|
5
|
+
import type { ThemeRuntime, ThemeSnapshot } from '@deepseek-ai/dsh-client-ui-theme/client'
|
|
6
|
+
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
|
|
7
|
+
import type {} from '@deepseek-ai/dsh-api-remotes/client'
|
|
8
|
+
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
|
9
|
+
import type {} from '@deepseek-ai/dsh-client-ui-theme/client'
|
|
10
|
+
import { EyeCareRow, type EyeCareRowInjected } from './EyeCareRow.tsx'
|
|
11
|
+
import { EyeCareController } from './controller.ts'
|
|
12
|
+
import { en, zh, type EyeCareKey } from './locales.ts'
|
|
13
|
+
|
|
14
|
+
/** Required browser services. */
|
|
15
|
+
export const inject = ['slots', 'locale', 'theme', 'connection', 'remote']
|
|
16
|
+
|
|
17
|
+
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
|
18
|
+
interface LocaleNamespaceMap {
|
|
19
|
+
/** Eye-care General row copy. */
|
|
20
|
+
'settings.eyeCare': EyeCareKey
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Browser context with the theme service declaration merged by ui-theme. */
|
|
25
|
+
type EyeCareClientContext = ClientContext & { theme: ThemeRuntime }
|
|
26
|
+
|
|
27
|
+
/** Register settings copy, controller lifecycle, and General row. */
|
|
28
|
+
export function apply(ctx: EyeCareClientContext): void {
|
|
29
|
+
const theme = ctx.get('theme') as ThemeRuntime
|
|
30
|
+
const connection = ctx.get('connection') as ConnectionHandle
|
|
31
|
+
const controller = new EyeCareController({
|
|
32
|
+
theme,
|
|
33
|
+
...(connection.isLoopback ? { rpc: connection.rpc } : {}),
|
|
34
|
+
subscribeTheme: (listener: (snapshot: ThemeSnapshot) => void): (() => void) => ctx.on('theme/change', listener),
|
|
35
|
+
})
|
|
36
|
+
ctx.effect(() => ctx.locale.register('settings.eyeCare', { zh, en }), 'dsh-eye-care: dictionaries')
|
|
37
|
+
ctx.effect(() => {
|
|
38
|
+
const refresh = (namespace?: string): void => {
|
|
39
|
+
if (namespace !== undefined && namespace !== 'eye-care') return
|
|
40
|
+
controller.refresh()
|
|
41
|
+
}
|
|
42
|
+
const disposers = [
|
|
43
|
+
ctx.remote.$on('settings/document-updated', refresh),
|
|
44
|
+
ctx.on('connection/reset', () => {
|
|
45
|
+
if (connection.isLoopback) controller.refresh()
|
|
46
|
+
}),
|
|
47
|
+
]
|
|
48
|
+
void controller.load()
|
|
49
|
+
return async () => {
|
|
50
|
+
for (const dispose of disposers) dispose()
|
|
51
|
+
await controller.dispose()
|
|
52
|
+
}
|
|
53
|
+
}, 'dsh-eye-care: controller lifecycle')
|
|
54
|
+
|
|
55
|
+
const injected = (): EyeCareRowInjected => ({
|
|
56
|
+
hooks: { eyeCare: controller.store },
|
|
57
|
+
setMode: mode => controller.setMode(mode),
|
|
58
|
+
setIntensity: intensity => controller.setIntensity(intensity),
|
|
59
|
+
})
|
|
60
|
+
ctx.slots.inject('settings.general.item', () => ctx.slots.register({
|
|
61
|
+
name: 'settings.general.item',
|
|
62
|
+
id: 'eye-care',
|
|
63
|
+
order: 15,
|
|
64
|
+
locale: 'settings.eyeCare',
|
|
65
|
+
inject: injected,
|
|
66
|
+
}, EyeCareRow))
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export type { EyeCareRowInjected, EyeCareRowProps } from './EyeCareRow.tsx'
|
|
70
|
+
export type { EyeCareState } from './controller.ts'
|
|
71
|
+
export { EyeCareController } from './controller.ts'
|
|
72
|
+
export { EYE_CARE_THEMES, eyeCareThemeId, isEyeCareThemeId } from './themes.ts'
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/** `settings.eyeCare` dictionaries. */
|
|
2
|
+
|
|
3
|
+
/** Simplified Chinese dictionary. */
|
|
4
|
+
export const zh = {
|
|
5
|
+
'title': '护眼模式',
|
|
6
|
+
'description': '降低页面的冷白光感,长时间阅读更柔和。',
|
|
7
|
+
'enabled.title': '护眼模式',
|
|
8
|
+
'enabled.off': '关闭',
|
|
9
|
+
'enabled.on': '开启',
|
|
10
|
+
'advanced.title': '显示方式与暖色强度',
|
|
11
|
+
'mode.title': '显示方式',
|
|
12
|
+
'mode.off': '关闭',
|
|
13
|
+
'mode.auto': '自动',
|
|
14
|
+
'mode.light': '日间',
|
|
15
|
+
'mode.dark': '夜间',
|
|
16
|
+
'intensity.title': '暖色强度',
|
|
17
|
+
'intensity.soft': '柔和',
|
|
18
|
+
'intensity.balanced': '均衡',
|
|
19
|
+
'intensity.warm': '温暖',
|
|
20
|
+
'status.off': '护眼模式已关闭',
|
|
21
|
+
'status.auto': '自动跟随系统明暗外观',
|
|
22
|
+
'status.light': '正在使用暖色日间主题',
|
|
23
|
+
'status.dark': '正在使用暖暗夜间主题',
|
|
24
|
+
'status.local': '仅当前浏览器有效',
|
|
25
|
+
'status.saving': '正在保存',
|
|
26
|
+
'status.error': '设置暂未保存',
|
|
27
|
+
} satisfies Record<string, string>
|
|
28
|
+
|
|
29
|
+
/** Dictionary key union. */
|
|
30
|
+
export type EyeCareKey = keyof typeof zh
|
|
31
|
+
|
|
32
|
+
/** English dictionary. */
|
|
33
|
+
export const en = {
|
|
34
|
+
'title': 'Eye care',
|
|
35
|
+
'description': 'Use warm semantic themes to reduce cool-white glare during long reading sessions',
|
|
36
|
+
'enabled.title': 'Eye care',
|
|
37
|
+
'enabled.off': 'Off',
|
|
38
|
+
'enabled.on': 'On',
|
|
39
|
+
'advanced.title': 'Appearance and warmth',
|
|
40
|
+
'mode.title': 'Appearance',
|
|
41
|
+
'mode.off': 'Off',
|
|
42
|
+
'mode.auto': 'Auto',
|
|
43
|
+
'mode.light': 'Day',
|
|
44
|
+
'mode.dark': 'Night',
|
|
45
|
+
'intensity.title': 'Warmth',
|
|
46
|
+
'intensity.soft': 'Soft',
|
|
47
|
+
'intensity.balanced': 'Balanced',
|
|
48
|
+
'intensity.warm': 'Warm',
|
|
49
|
+
'status.off': 'Eye care is off',
|
|
50
|
+
'status.auto': 'Following the system light or dark appearance',
|
|
51
|
+
'status.light': 'Warm day theme is active',
|
|
52
|
+
'status.dark': 'Warm night theme is active',
|
|
53
|
+
'status.local': 'Current browser only',
|
|
54
|
+
'status.saving': 'Saving',
|
|
55
|
+
'status.error': 'Setting is not saved yet',
|
|
56
|
+
} satisfies Record<EyeCareKey, string>
|