From fc7d780cba4f77d7906cca09614325800f756b43 Mon Sep 17 00:00:00 2001 From: sean2077 <37324769+sean2077@users.noreply.github.com> Date: Tue, 21 Jul 2026 12:01:44 +0800 Subject: [PATCH 1/3] feat: migrate API credentials to SecretStorage --- AGENTS.md | 1 + README.md | 6 +- README.zh.md | 6 +- docs/development.md | 7 +- manifest.json | 2 +- package-lock.json | 2 +- package.json | 2 +- src/core/credential-storage.ts | 235 ++++++++++++++++++++++ src/core/index.ts | 6 + src/core/obsidian-compat.ts | 13 ++ src/core/settings.ts | 15 ++ src/default-settings.ts | 2 + src/i18n/en.ts | 9 +- src/i18n/zh-cn.ts | 9 +- src/modals/wallpaper-api-modal.ts | 132 ++++++++---- src/plugin.ts | 63 +++++- src/settings/sections/api-settings.ts | 42 ++-- src/settings/settings-tab.ts | 127 +++++++++++- src/types.ts | 1 + src/wallpaper-apis/core/types.ts | 12 +- styles.css | 4 + tests/credential-storage.test.ts | 220 ++++++++++++++++++++ tests/lifecycle.test.ts | 3 +- tests/release-contract.test.ts | 7 +- tests/settings-migration-contract.test.ts | 33 +++ tests/settings.test.ts | 1 + 26 files changed, 883 insertions(+), 77 deletions(-) create mode 100644 src/core/credential-storage.ts create mode 100644 tests/credential-storage.test.ts create mode 100644 tests/settings-migration-contract.test.ts diff --git a/AGENTS.md b/AGENTS.md index 20a32ed..ea21718 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,6 +8,7 @@ Dynamic Theme Background is a TypeScript/CSS Obsidian community plugin for deskt - Preserve cross-platform support (`manifest.json` has `isDesktopOnly: false`); Node/Electron-only behavior requires an explicit, guarded desktop boundary. - Keep `src/plugin.ts` as the lifecycle and composition root; background transitions, scheduling, styling, persistence, and internal events belong in the focused services under `src/core/`. - Keep setting types, defaults, UI, persistence compatibility, and both source locale files aligned when configuration changes. +- Keep provider credentials and custom-header values in Obsidian SecretStorage: persist only references, migrate legacy plaintext all-or-retain, and hydrate isolated runtime config clones. - Let semantic-release own normal version synchronization across `manifest.json`, `package.json`, `package-lock.json`, and `src/version.ts`; do not manually bump them outside a release task. - Run `npm run check` for the complete automated gate; use `npm test`, `npm run build`, `npm run lint`, or `npm run lint:css` for focused iteration. Record the relevant manual Obsidian checks for behavior changes that require a real app, theme, or device. diff --git a/README.md b/README.md index 8b31873..3bedfd8 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ ## Installation -> Requires Obsidian 1.7.2 or later. +> Requires Obsidian 1.11.4 or later. ### Marketplace (Pending) @@ -78,7 +78,9 @@ ## Network and Privacy - Local images, colors, and gradients work without provider requests. Enabling a wallpaper provider, using a custom JSON endpoint, or applying/saving a remote image can contact the configured third party; that service's terms and privacy policy apply. -- Provider keys, tokens, and custom headers are stored in the plugin's `data.json` under the vault configuration so Obsidian 1.7.2 remains supported. This file is not encrypted: do not publish it, review vault-sync destinations, and rotate any credential that may have been exposed. +- Provider keys, tokens, and every custom-header value are stored through Obsidian SecretStorage. The plugin's `data.json` contains only non-secret settings and SecretStorage IDs, not those credential values. +- On the first load after upgrading, legacy plaintext credentials are copied to SecretStorage before `data.json` is replaced. If any secret write fails, the old `data.json` is left unchanged and plugin startup stops so credentials are not lost or partially migrated. +- Secrets can be shared across plugins or configurations, so deleting a DTB API removes only its reference and does not delete the Obsidian-owned secret. - The plugin has no telemetry or analytics. Diagnostic logging sanitizes credential-bearing URLs and headers, bounds nested data, and does not intentionally log API keys or tokens. - Saving a remote wallpaper is an explicit action and writes only to the validated vault-relative folder selected in plugin settings. diff --git a/README.zh.md b/README.zh.md index 2ccbe22..fb47dbb 100644 --- a/README.zh.md +++ b/README.zh.md @@ -46,7 +46,7 @@ ## 安装 -> 需要 Obsidian 1.7.2 或更高版本。 +> 需要 Obsidian 1.11.4 或更高版本。 ### 社区市场(审核中) @@ -78,7 +78,9 @@ ## 网络与隐私 - 本地图片、纯色和渐变无需请求壁纸服务。启用壁纸提供商、使用自定义 JSON 接口或应用/保存远程图片时,可能访问所配置的第三方;相应服务的条款与隐私政策适用。 -- 为继续支持 Obsidian 1.7.2,提供商密钥、令牌和自定义请求头保存在 vault 配置目录下的插件 `data.json` 中。该文件未加密:请勿公开,检查 vault 同步目的地,并轮换任何可能泄露的凭据。 +- 提供商密钥、令牌和所有自定义请求头值通过 Obsidian SecretStorage 保存。插件的 `data.json` 只包含非敏感设置和 SecretStorage ID,不包含这些凭据值。 +- 升级后首次加载时,旧版明文凭据会先完整写入 SecretStorage,再替换 `data.json`。若任一密钥写入失败,旧 `data.json` 会保持不变并停止插件启动,避免凭据丢失或半迁移。 +- 密钥可能由多个插件或配置共享,因此删除 DTB API 只会移除引用,不会删除由 Obsidian 管理的密钥。 - 插件不包含遥测或分析。诊断日志会清理带凭据的 URL 与请求头、限制嵌套数据规模,并且不会有意记录 API key 或 token。 - 保存远程壁纸必须由用户明确触发,且只会写入插件设置中经过校验的 vault 相对目录。 diff --git a/docs/development.md b/docs/development.md index 452e537..4531061 100644 --- a/docs/development.md +++ b/docs/development.md @@ -6,7 +6,7 @@ This guide holds lower-frequency implementation detail referenced by the root `A - `src/main.ts` is the bundle entry and re-exports the plugin class from `src/plugin.ts`. - `src/plugin.ts` is the lifecycle and composition root: it loads and saves settings, constructs services, registers commands and UI, and exposes compatibility proxies used by existing surfaces. -- `src/core/` owns focused runtime modules: lifecycle coordination, time rules, settings normalization, network and persistence policy, DOM/CSS application, remote image storage, and typed internal notifications. +- `src/core/` owns focused runtime modules: lifecycle coordination, time rules, settings normalization and credential migration, network and persistence policy, DOM/CSS application, remote image storage, and typed internal notifications. - `src/types.ts` and `src/default-settings.ts` define the settings contract and defaults; `src/settings/settings-tab.ts` composes the focused sections under `src/settings/sections/`, while `settings-view.ts` owns the alternate settings view. - `src/commands/index.ts` is the command registration point. - `src/wallpaper-apis/core/` owns provider contracts, validation, registry, state, and lifecycle; provider modules under `src/wallpaper-apis/providers/` self-register and are exported through `providers/index.ts`. @@ -35,6 +35,7 @@ Automated tests cover pure policies and structural lifecycle/release contracts. - enable and disable the plugin, confirming timers and the `.dtb-enabled` body class are cleaned up; - exercise the affected manual, interval, or time-rule background path; - save settings, reload the plugin, and confirm existing saved data still behaves correctly; +- in a disposable vault with legacy provider credentials, reload once and confirm provider parameters and custom headers still work while `data.json` contains only SecretStorage references; also test a missing reference and confirm that provider stays unavailable without hiding its editable row; - inspect both light and dark themes when CSS variables or overlays change; - exercise provider success, failure, and local-background fallback when wallpaper API behavior changes. @@ -44,6 +45,10 @@ Automated tests cover pure policies and structural lifecycle/release contracts. Persisted data passes through `src/core/settings.ts`, which clones defaults and validates scalar and nested values before runtime use. When the schema changes, update the type, default, normalizer, relevant settings UI, tests, and `src/i18n/en.ts` plus `src/i18n/zh-cn.ts`. +Provider password fields and every user-defined header value are persisted as SecretStorage IDs under `secretRefs`. `src/core/credential-storage.ts` owns lossless legacy migration, the plaintext save guard, and short-lived runtime hydration; `src/plugin.ts` is the only composition boundary that supplies `app.secretStorage`. Never pass a persisted provider config directly to `WallpaperApiManager` or write hydrated values back to plugin settings. + +Obsidian 1.13+ renders `DTBSettingTab.getSettingDefinitions()` as declarative pages. Keep `display()` and the alternate settings view as the Obsidian 1.11.4–1.12 compatibility path; new 1.13-only calls must stay behind `requireApiVersion` guards in `src/core/obsidian-compat.ts`. + ### Commands Create or update the command module under `src/commands/`, then keep the central list in `src/commands/index.ts` aligned. Command callbacks that return promises must preserve the repository's no-floating-promise rules. diff --git a/manifest.json b/manifest.json index 5091c3a..f3c3f44 100644 --- a/manifest.json +++ b/manifest.json @@ -2,7 +2,7 @@ "id": "dynamic-theme-background", "name": "Dynamic Theme Background", "version": "2.9.2", - "minAppVersion": "1.7.2", + "minAppVersion": "1.11.4", "description": "Build Your Own Wallpaper Library! Make every note-taking experience visually inspiring.", "author": "sean2077", "authorUrl": "https://github.com/sean2077", diff --git a/package-lock.json b/package-lock.json index ee8dfed..4638a14 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,7 @@ "eslint-plugin-obsidianmd": "^0.4.1", "globals": "^17.6.0", "jiti": "^2.6.1", - "obsidian": "^1.10.3", + "obsidian": "^1.13.1", "prettier": "^3.7.3", "stylelint": "^16.26.1", "stylelint-config-idiomatic-order": "^10.0.0", diff --git a/package.json b/package.json index a2e6c3a..b54f63e 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "eslint-plugin-obsidianmd": "^0.4.1", "globals": "^17.6.0", "jiti": "^2.6.1", - "obsidian": "^1.10.3", + "obsidian": "^1.13.1", "prettier": "^3.7.3", "stylelint": "^16.26.1", "stylelint-config-idiomatic-order": "^10.0.0", diff --git a/src/core/credential-storage.ts b/src/core/credential-storage.ts new file mode 100644 index 0000000..6062fa1 --- /dev/null +++ b/src/core/credential-storage.ts @@ -0,0 +1,235 @@ +import type { DTBSettings } from "../types"; +import type { + ApiValueType, + WallpaperApiConfig, + WallpaperApiSecretRefs, +} from "../wallpaper-apis/core/types"; + +export const CURRENT_CREDENTIAL_STORAGE_VERSION = 1; + +const SECRET_ID_PREFIX = "dynamic-theme-background"; +const SECRET_ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; + +export interface SecretStore { + getSecret(id: string): string | null; + setSecret(id: string, secret: string): void; +} + +export type SensitiveParamResolver = (config: WallpaperApiConfig) => readonly string[]; + +export interface CredentialMigrationResult { + settings: DTBSettings; + migrated: boolean; +} + +export class CredentialMigrationError extends Error { + constructor() { + super("Credential migration failed"); + } +} + +export class MissingSecretReferenceError extends Error { + constructor(readonly apiId: string, readonly field: string) { + super("Credential reference missing"); + } +} + +function cloneSecretRefs(secretRefs: WallpaperApiSecretRefs | undefined): WallpaperApiSecretRefs | undefined { + if (!secretRefs) return undefined; + return { + params: secretRefs.params ? { ...secretRefs.params } : undefined, + headers: secretRefs.headers ? { ...secretRefs.headers } : undefined, + }; +} + +function cloneConfig(config: WallpaperApiConfig): WallpaperApiConfig { + return { + ...config, + endpoints: config.endpoints ? { ...config.endpoints } : undefined, + headers: config.headers ? { ...config.headers } : undefined, + params: { ...config.params }, + secretRefs: cloneSecretRefs(config.secretRefs), + customSettings: config.customSettings ? { ...config.customSettings } : undefined, + }; +} + +function cloneSettings(settings: DTBSettings): DTBSettings { + return { + ...settings, + wallpaperApis: settings.wallpaperApis.map(cloneConfig), + }; +} + +function normalizeSecretIdPart(value: string): string { + const normalized = value + .toLowerCase() + .replace(/[^a-z0-9]+/gu, "-") + .replace(/^-+|-+$/gu, ""); + return normalized || "field"; +} + +function baseSecretId(config: WallpaperApiConfig, kind: "header" | "param", field: string): string { + return [SECRET_ID_PREFIX, normalizeSecretIdPart(config.id), kind, normalizeSecretIdPart(field)].join("-"); +} + +function findAvailableSecretId( + baseId: string, + secret: string, + store: SecretStore, + pendingWrites: Map +): string { + let suffix = 1; + while (true) { + const candidate = suffix === 1 ? baseId : `${baseId}-${suffix}`; + const currentValue = pendingWrites.get(candidate) ?? store.getSecret(candidate); + if (currentValue === null || currentValue === secret) { + return candidate; + } + suffix += 1; + } +} + +function ensureReference( + config: WallpaperApiConfig, + kind: "header" | "param", + field: string, + secret: string, + currentReference: string | undefined, + store: SecretStore, + pendingWrites: Map +): string { + if (currentReference) { + const currentValue = pendingWrites.get(currentReference) ?? store.getSecret(currentReference); + if (currentValue !== null && currentValue !== secret) { + throw new CredentialMigrationError(); + } + if (currentValue === null) { + pendingWrites.set(currentReference, secret); + } + return currentReference; + } + + const id = findAvailableSecretId(baseSecretId(config, kind, field), secret, store, pendingWrites); + if ((pendingWrites.get(id) ?? store.getSecret(id)) !== secret) { + pendingWrites.set(id, secret); + } + return id; +} + +function compactSecretRefs(secretRefs: WallpaperApiSecretRefs): WallpaperApiSecretRefs | undefined { + const params = secretRefs.params && Object.keys(secretRefs.params).length > 0 ? secretRefs.params : undefined; + const headers = secretRefs.headers && Object.keys(secretRefs.headers).length > 0 ? secretRefs.headers : undefined; + return params || headers ? { params, headers } : undefined; +} + +/** + * Copies legacy credentials into SecretStorage and returns a sanitized settings clone. + * The caller should persist the returned clone only after this function succeeds. + */ +export function migrateSettingsCredentials( + source: DTBSettings, + store: SecretStore, + resolveSensitiveParams: SensitiveParamResolver +): CredentialMigrationResult { + const settings = cloneSettings(source); + const pendingWrites = new Map(); + let migrated = settings.credentialStorageVersion < CURRENT_CREDENTIAL_STORAGE_VERSION; + + try { + for (const config of settings.wallpaperApis) { + const refs = cloneSecretRefs(config.secretRefs) ?? {}; + refs.params ??= {}; + refs.headers ??= {}; + + for (const field of resolveSensitiveParams(config)) { + if (!(field in config.params)) continue; + const rawValue = config.params[field]; + delete config.params[field]; + migrated = true; + if (rawValue === "") continue; + const secret = String(rawValue); + refs.params[field] = ensureReference( + config, + "param", + field, + secret, + refs.params[field], + store, + pendingWrites + ); + } + + for (const [field, secret] of Object.entries(config.headers ?? {})) { + migrated = true; + if (secret === "") continue; + refs.headers[field] = ensureReference( + config, + "header", + field, + secret, + refs.headers[field], + store, + pendingWrites + ); + } + + config.headers = undefined; + config.secretRefs = compactSecretRefs(refs); + } + + for (const [id, secret] of pendingWrites) { + if (!SECRET_ID_PATTERN.test(id)) { + throw new CredentialMigrationError(); + } + store.setSecret(id, secret); + } + } catch (error) { + if (error instanceof CredentialMigrationError) throw error; + throw new CredentialMigrationError(); + } + + settings.credentialStorageVersion = CURRENT_CREDENTIAL_STORAGE_VERSION; + return { settings, migrated }; +} + +function hydrateReferences( + target: Record | Record, + refs: Record | undefined, + config: WallpaperApiConfig, + store: SecretStore, + kind: "header" | "param" +): void { + for (const [field, id] of Object.entries(refs ?? {})) { + const secret = store.getSecret(id); + if (secret === null) { + throw new MissingSecretReferenceError(config.id, `${kind}:${field}`); + } + target[field] = secret; + } +} + +/** Returns a short-lived runtime clone containing the resolved credential values. */ +export function hydrateWallpaperApiConfig(config: WallpaperApiConfig, store: SecretStore): WallpaperApiConfig { + const hydrated = cloneConfig(config); + hydrated.headers ??= {}; + hydrateReferences(hydrated.params, hydrated.secretRefs?.params, config, store, "param"); + hydrateReferences(hydrated.headers, hydrated.secretRefs?.headers, config, store, "header"); + if (Object.keys(hydrated.headers).length === 0) { + hydrated.headers = undefined; + } + return hydrated; +} + +/** Prevents any future settings write from reintroducing plaintext credentials. */ +export function assertSettingsCredentialsAreReferences( + settings: DTBSettings, + resolveSensitiveParams: SensitiveParamResolver +): void { + for (const config of settings.wallpaperApis) { + const hasSensitiveParam = resolveSensitiveParams(config).some((field) => field in config.params); + const hasHeaders = Object.keys(config.headers ?? {}).length > 0; + if (hasSensitiveParam || hasHeaders) { + throw new Error("Refusing to persist plaintext credentials in wallpaper API settings"); + } + } +} diff --git a/src/core/index.ts b/src/core/index.ts index 4579f75..727202c 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -1,4 +1,10 @@ export { BackgroundManager } from "./background-manager"; +export { + MissingSecretReferenceError, + assertSettingsCredentialsAreReferences, + hydrateWallpaperApiConfig, + migrateSettingsCredentials, +} from "./credential-storage"; export { selectBackgroundPlan } from "./background-selection"; export type { BackgroundSelectionPlan } from "./background-selection"; export { BackgroundPersistence } from "./background-persistence"; diff --git a/src/core/obsidian-compat.ts b/src/core/obsidian-compat.ts index 630e42b..404c994 100644 --- a/src/core/obsidian-compat.ts +++ b/src/core/obsidian-compat.ts @@ -9,6 +9,10 @@ type ImperativeSettingSurface = { display(): void; }; +type DualSettingSurface = ImperativeSettingSurface & { + update(): void; +}; + /** Preserve visible slider values on supported Obsidian versions before 1.13. */ export function preserveSliderValueVisibility(slider: SliderComponent): SliderComponent { if (!requireApiVersion("1.13.0")) { @@ -21,3 +25,12 @@ export function preserveSliderValueVisibility(slider: SliderComponent): SliderCo export function displayImperativeSettings(surface: ImperativeSettingSurface): void { surface.display(); } + +/** Refresh declarative definitions on 1.13+, while preserving the older imperative path. */ +export function refreshDualSettings(surface: DualSettingSurface): void { + if (requireApiVersion("1.13.0")) { + surface.update(); + } else { + surface.display(); + } +} diff --git a/src/core/settings.ts b/src/core/settings.ts index 01a987b..50f9450 100644 --- a/src/core/settings.ts +++ b/src/core/settings.ts @@ -91,11 +91,20 @@ function isWallpaperApi(value: unknown): value is WallpaperApiConfig { isOptionalString(value.description) && isValueRecord(value.params, isApiValue) && (value.headers === undefined || isValueRecord(value.headers, (entry) => typeof entry === "string")) && + (value.secretRefs === undefined || isSecretRefs(value.secretRefs)) && (value.endpoints === undefined || isValueRecord(value.endpoints, isOptionalString)) && (value.customSettings === undefined || isValueRecord(value.customSettings, isOptionalString)) ); } +function isSecretRefs(value: unknown): boolean { + return ( + isRecord(value) && + (value.params === undefined || isValueRecord(value.params, (entry) => typeof entry === "string")) && + (value.headers === undefined || isValueRecord(value.headers, (entry) => typeof entry === "string")) + ); +} + function arrayOrDefault(value: unknown, fallback: T[], predicate: (entry: unknown) => entry is T): T[] { return Array.isArray(value) && value.every(predicate) ? clone(value) : fallback; } @@ -139,6 +148,12 @@ export function normalizeSettings(loaded: unknown, defaults: DTBSettings): DTBSe ); return { + credentialStorageVersion: integerOrDefault( + loaded.credentialStorageVersion, + 0, + 0, + Number.MAX_SAFE_INTEGER + ), enabled: booleanOrDefault(loaded.enabled, fallback.enabled), statusBarEnabled: booleanOrDefault(loaded.statusBarEnabled, fallback.statusBarEnabled), blurDepth: numberOrDefault(loaded.blurDepth, fallback.blurDepth, 0, 30), diff --git a/src/default-settings.ts b/src/default-settings.ts index 61c8bba..8be2855 100644 --- a/src/default-settings.ts +++ b/src/default-settings.ts @@ -4,6 +4,7 @@ */ import { t } from "./i18n"; +import { CURRENT_CREDENTIAL_STORAGE_VERSION } from "./core/credential-storage"; import type { DTBSettings } from "./types"; import { apiRegistry, WallpaperApiType } from "./wallpaper-apis"; @@ -11,6 +12,7 @@ let DEFAULT_SETTINGS: DTBSettings; function genDefaultSettings(): DTBSettings { DEFAULT_SETTINGS = { + credentialStorageVersion: CURRENT_CREDENTIAL_STORAGE_VERSION, enabled: true, statusBarEnabled: true, // 是否激活状态栏 diff --git a/src/i18n/en.ts b/src/i18n/en.ts index b6840c9..54eb1dd 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -218,10 +218,11 @@ export default { api_modal_test_rejected: "The provider rejected the configuration or could not connect", api_modal_test_unexpected: "An unexpected error occurred; check the developer console", api_modal_api_url: "API URL", - api_modal_headers_optional: "Headers (optional)", + api_modal_headers_optional: "Header secrets (optional)", api_modal_add_header: "Add header", api_modal_header_key: "Header key", - api_modal_header_value: "Header value", + api_modal_header_secret: "Obsidian secret", + api_modal_secret_missing: "Choose an Obsidian secret for every required credential and header.", api_modal_api_parameters: "API parameters", api_modal_api_documentation: "📖 API documentation", api_modal_token_url: "🔑 Token URL", @@ -242,6 +243,10 @@ export default { api_modal_image_url_json_path: "Image URL JSON path", api_modal_no_description: "No description provided.", api_modal_unnamed_api: "Unnamed API", + notice_api_secret_missing: "A referenced Obsidian secret is missing. Edit the API configuration.", + notice_api_secret_unavailable: "Wallpaper API credentials are unavailable. Edit the API configuration.", + notice_credential_migration_failed: + "DTB could not move API credentials to Obsidian SecretStorage. Existing settings were left unchanged and plugin startup stopped.", api_initialized_notice: "{apiName} initialized: {count} wallpapers found across {pages} pages.", // ===== 状态文本 ===== diff --git a/src/i18n/zh-cn.ts b/src/i18n/zh-cn.ts index 72aa4c9..7c3482d 100644 --- a/src/i18n/zh-cn.ts +++ b/src/i18n/zh-cn.ts @@ -201,10 +201,11 @@ export default { api_modal_test_rejected: "提供商拒绝了该配置或无法连接", api_modal_test_unexpected: "发生意外错误,请检查开发者控制台", api_modal_api_url: "API 地址", - api_modal_headers_optional: "请求头(可选)", + api_modal_headers_optional: "请求头密钥(可选)", api_modal_add_header: "添加请求头", api_modal_header_key: "请求头键", - api_modal_header_value: "请求头值", + api_modal_header_secret: "Obsidian 密钥", + api_modal_secret_missing: "请为每个必需凭据和请求头选择一个 Obsidian 密钥。", api_modal_api_parameters: "API 参数", api_modal_api_documentation: "📖 API 文档", api_modal_token_url: "🔑 Token 获取地址", @@ -225,6 +226,10 @@ export default { api_modal_image_url_json_path: "图片 URL JSON 路径", api_modal_no_description: "未提供描述。", api_modal_unnamed_api: "未命名的 API", + notice_api_secret_missing: "引用的 Obsidian 密钥不存在,请编辑 API 配置。", + notice_api_secret_unavailable: "壁纸 API 凭据不可用,请编辑 API 配置。", + notice_credential_migration_failed: + "DTB 无法将 API 凭据迁移到 Obsidian SecretStorage。原设置保持不变,插件启动已停止。", api_initialized_notice: "{apiName} 初始化完成:在 {pages} 页中找到 {count} 张壁纸。", // ===== 状态文本 ===== diff --git a/src/modals/wallpaper-api-modal.ts b/src/modals/wallpaper-api-modal.ts index a2648b8..d935e23 100644 --- a/src/modals/wallpaper-api-modal.ts +++ b/src/modals/wallpaper-api-modal.ts @@ -1,5 +1,6 @@ -import { App, Modal, Notice } from "obsidian"; +import { Modal, Notice, SecretComponent } from "obsidian"; import { logger } from "../core/logger"; +import type DynamicThemeBackgroundPlugin from "../plugin"; import { generateId } from "../utils/utils"; import { isRecord } from "../utils/type-guards"; import { t } from "../i18n"; @@ -18,8 +19,9 @@ import { * 壁纸 API 编辑模态框类,用于在 Obsidian 插件中创建和编辑壁纸 API 配置。 */ export class WallpaperApiEditorModal extends Modal { + plugin: DynamicThemeBackgroundPlugin; apiConfig: WallpaperApiConfig; - onSubmit: (apiConfig: WallpaperApiConfig) => void; + onSubmit: (apiConfig: WallpaperApiConfig) => void | Promise; // 基础配置输入元素 nameInput!: HTMLInputElement; @@ -28,12 +30,13 @@ export class WallpaperApiEditorModal extends Modal { urlInput!: HTMLInputElement; // Headers配置 headersContainer!: HTMLDivElement; - headerInputs: Array<{ key: HTMLInputElement; value: HTMLInputElement }> = []; + headerInputs: Array<{ key: HTMLInputElement; secretId: string }> = []; // 参数配置容器的引用 paramsSectionContainer!: HTMLElement; // 动态参数输入元素映射 paramInputs: Map = new Map(); + secretParamRefs: Map = new Map(); // 额外参数输入 extraParamsTextarea!: HTMLTextAreaElement; @@ -42,8 +45,13 @@ export class WallpaperApiEditorModal extends Modal { // 自定义设置输入元素 customSettingsInputs: Map = new Map(); - constructor(app: App, apiConfig: WallpaperApiConfig, onSubmit: (apiConfig: WallpaperApiConfig) => void) { - super(app); + constructor( + plugin: DynamicThemeBackgroundPlugin, + apiConfig: WallpaperApiConfig, + onSubmit: (apiConfig: WallpaperApiConfig) => void | Promise + ) { + super(plugin.app); + this.plugin = plugin; this.apiConfig = apiConfig; this.onSubmit = onSubmit; } @@ -138,10 +146,10 @@ export class WallpaperApiEditorModal extends Modal { container.createEl("label", { text: t("api_modal_headers_optional") }); this.headersContainer = container.createDiv("dtb-list-container"); - // 渲染已有的headers - if (this.apiConfig.headers) { - Object.entries(this.apiConfig.headers).forEach(([key, value]) => { - this.addHeaderInput(key, value); + // 渲染已有的 SecretStorage 引用 + if (this.apiConfig.secretRefs?.headers) { + Object.entries(this.apiConfig.secretRefs.headers).forEach(([key, secretId]) => { + this.addHeaderInput(key, secretId); }); } @@ -155,7 +163,7 @@ export class WallpaperApiEditorModal extends Modal { } // 添加header输入行 - private addHeaderInput(key = "", value = "") { + private addHeaderInput(key = "", secretId = "") { const headerRow = this.headersContainer.createDiv("dtb-list-row"); const keyInput = headerRow.createEl("input", { @@ -165,11 +173,11 @@ export class WallpaperApiEditorModal extends Modal { cls: "dtb-input", }); - const valueInput = headerRow.createEl("input", { - type: "text", - value: value, - placeholder: t("api_modal_header_value"), - cls: "dtb-input", + const secretContainer = headerRow.createDiv("dtb-secret-control"); + secretContainer.setAttribute("aria-label", t("api_modal_header_secret")); + const headerInput = { key: keyInput, secretId }; + new SecretComponent(this.app, secretContainer).setValue(secretId).onChange((value) => { + headerInput.secretId = value; }); const removeBtn = headerRow.createEl("button", { @@ -185,7 +193,7 @@ export class WallpaperApiEditorModal extends Modal { } }; - this.headerInputs.push({ key: keyInput, value: valueInput }); + this.headerInputs.push(headerInput); } // 创建参数配置部分 @@ -198,6 +206,7 @@ export class WallpaperApiEditorModal extends Modal { private refreshParamsSection() { // 清除现有的参数输入 this.paramInputs.clear(); + this.secretParamRefs.clear(); // 使用保存的容器引用而不是DOM查询 if (!this.paramsSectionContainer) return; @@ -291,11 +300,20 @@ export class WallpaperApiEditorModal extends Modal { // 创建动态参数输入 private createDynamicParamInputs(container: HTMLElement, descriptors: WallpaperApiParamDescriptor[]) { + const selectedType = this.typeSelect.value as WallpaperApiType; this.createDynamicInputs( container, descriptors, (key) => this.apiConfig.params[key], - (key, input) => this.paramInputs.set(key, input) + (key, input) => this.paramInputs.set(key, input), + (key) => (selectedType === this.apiConfig.type ? this.apiConfig.secretRefs?.params?.[key] : undefined), + (key, secretId) => { + if (secretId) { + this.secretParamRefs.set(key, secretId); + } else { + this.secretParamRefs.delete(key); + } + } ); } @@ -308,7 +326,7 @@ export class WallpaperApiEditorModal extends Modal { }); // 构建额外参数JSON - const knownKeys = Array.from(this.paramInputs.keys()); + const knownKeys = this.getParamDescriptorsForType(this.typeSelect.value).map((descriptor) => descriptor.key); const extraParams: Record = {}; Object.entries(this.apiConfig.params).forEach(([key, value]) => { @@ -381,7 +399,9 @@ export class WallpaperApiEditorModal extends Modal { container: HTMLElement, descriptors: WallpaperApiParamDescriptor[], getCurrentValue: (key: string) => OptionalApiValueType, - setInput: (key: string, input: HTMLElement) => void + setInput: (key: string, input: HTMLElement) => void, + getCurrentSecretRef?: (key: string) => string | undefined, + setSecretRef?: (key: string, secretId: string) => void ) { descriptors.forEach((descriptor) => { const paramContainer = container.createDiv("dtb-field"); @@ -396,6 +416,17 @@ export class WallpaperApiEditorModal extends Modal { }); } + if (descriptor.type === "password" && getCurrentSecretRef && setSecretRef) { + const currentRef = getCurrentSecretRef(descriptor.key) ?? ""; + const secretContainer = paramContainer.createDiv("dtb-secret-control"); + secretContainer.setAttribute("aria-label", descriptor.label); + setSecretRef(descriptor.key, currentRef); + new SecretComponent(this.app, secretContainer).setValue(currentRef).onChange((secretId) => { + setSecretRef(descriptor.key, secretId); + }); + return; + } + // 使用转换函数处理当前值,转为 UI 显示值 let currentValue: OptionalUiValueType = getCurrentValue(descriptor.key) ?? descriptor.defaultValue; if (descriptor.fromApiValue && currentValue !== undefined) { @@ -443,7 +474,6 @@ export class WallpaperApiEditorModal extends Modal { break; case "password": input = this.createStringInput(container, descriptor, currentValue); - (input as HTMLInputElement).type = "password"; break; default: // string input = this.createStringInput(container, descriptor, currentValue); @@ -574,8 +604,8 @@ export class WallpaperApiEditorModal extends Modal { text: t("api_modal_save"), cls: ["dtb-button", "mod-cta"], }); - submitButton.onclick = () => { - this.saveApiConfig(); + submitButton.onclick = async () => { + await this.saveApiConfig(); }; } @@ -616,14 +646,25 @@ export class WallpaperApiEditorModal extends Modal { new Notice(t("api_modal_invalid_json")); } } + for (const descriptor of paramDescriptors) { + if (descriptor.type === "password") { + delete params[descriptor.key]; + } + } - // 收集headers - const headers: Record = {}; - this.headerInputs.forEach(({ key, value }) => { - if (key.value.trim() && value.value.trim()) { - headers[key.value.trim()] = value.value.trim(); + // 收集 SecretStorage 引用;请求头值本身不会进入配置对象 + const headerSecretRefs: Record = {}; + this.headerInputs.forEach(({ key, secretId }) => { + if (key.value.trim() && secretId) { + headerSecretRefs[key.value.trim()] = secretId; } }); + const paramSecretRefs: Record = {}; + this.secretParamRefs.forEach((secretId, key) => { + if (secretId) paramSecretRefs[key] = secretId; + }); + const hasParamSecrets = Object.keys(paramSecretRefs).length > 0; + const hasHeaderSecrets = Object.keys(headerSecretRefs).length > 0; return { id: this.apiConfig.id || generateId("api"), @@ -633,7 +674,13 @@ export class WallpaperApiEditorModal extends Modal { baseUrl: this.urlInput.value, enabled: this.apiConfig.enabled ?? false, // 默认不启用 params, - headers: Object.keys(headers).length > 0 ? headers : undefined, + secretRefs: + hasParamSecrets || hasHeaderSecrets + ? { + params: hasParamSecrets ? paramSecretRefs : undefined, + headers: hasHeaderSecrets ? headerSecretRefs : undefined, + } + : undefined, customSettings: Object.keys(customSettings).length > 0 ? customSettings : undefined, }; } @@ -693,23 +740,31 @@ export class WallpaperApiEditorModal extends Modal { return result; } - validateApiConfig(config: WallpaperApiConfig): boolean { + private prepareValidatedConfig(config: WallpaperApiConfig): WallpaperApiConfig | null { + let runtimeConfig: WallpaperApiConfig; + try { + runtimeConfig = this.plugin.prepareWallpaperApiConfig(config); + } catch { + new Notice(t("api_modal_secret_missing")); + return null; + } + // 调用 API 注册器验证参数 - const validation = apiRegistry.validateParams(config.type, config.params); + const validation = apiRegistry.validateParams(runtimeConfig.type, runtimeConfig.params); if (!validation.valid) { new Notice(t("api_modal_invalid_params", { errors: validation.errors?.join(", ") ?? "Unknown error" })); - return false; + return null; } - return true; + return runtimeConfig; } // 保存API配置 - saveApiConfig() { + async saveApiConfig(): Promise { const config = this.buildApiConfig(); - if (!this.validateApiConfig(config)) { + if (!this.prepareValidatedConfig(config)) { return; } - this.onSubmit(config); + await this.onSubmit(config); this.close(); } @@ -719,15 +774,16 @@ export class WallpaperApiEditorModal extends Modal { try { new Notice(t("api_modal_testing_config")); - const config = this.buildApiConfig(); - if (!this.validateApiConfig(config)) { + const storedConfig = this.buildApiConfig(); + const runtimeConfig = this.prepareValidatedConfig(storedConfig); + if (!runtimeConfig) { new Notice(t("api_modal_cannot_test_invalid")); return; } // 使用临时 ID 避免覆盖同 ID 的已有实例 testApiId = generateId("api-test"); - const testConfig = { ...config, enabled: false, id: testApiId }; + const testConfig = { ...runtimeConfig, enabled: false, id: testApiId }; await apiManager.createApi(testConfig, false); const success = apiManager.getApiById(testApiId) ? await apiManager.enableApi(testApiId) : false; diff --git a/src/plugin.ts b/src/plugin.ts index 00332a7..e8fa2dc 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -5,21 +5,32 @@ import { Notice, Plugin } from "obsidian"; import { registerCommands } from "./commands"; -import { displayImperativeSettings } from "./core/obsidian-compat"; import { BackgroundManager, BackgroundPersistence, EventBus, + MissingSecretReferenceError, StyleManager, TimeRuleScheduler, + assertSettingsCredentialsAreReferences, + hydrateWallpaperApiConfig, logger, + migrateSettingsCredentials, normalizeSettings, } from "./core"; import { getDefaultSettings } from "./default-settings"; import { t } from "./i18n"; import { DTBSettingTab, DTBSettingsView, DTB_SETTINGS_VIEW_TYPE } from "./settings"; import type { BackgroundItem, DTBSettings } from "./types"; -import { apiManager } from "./wallpaper-apis"; +import { apiManager, apiRegistry } from "./wallpaper-apis"; +import type { WallpaperApiConfig } from "./wallpaper-apis"; + +function sensitiveParamKeys(config: WallpaperApiConfig): string[] { + return apiRegistry + .getParamDescriptors(config.type) + .filter((descriptor) => descriptor.type === "password") + .map((descriptor) => descriptor.key); +} export default class DynamicThemeBackgroundPlugin extends Plugin { settings!: DTBSettings; @@ -94,7 +105,7 @@ export default class DynamicThemeBackgroundPlugin extends Plugin { private async initializeRuntime(generation: number): Promise { for (const apiConfig of this.settings.wallpaperApis) { if (generation !== this.lifecycleGeneration) return; - await apiManager.createApi(apiConfig, false); + await this.createWallpaperApi(apiConfig, false); } if (generation === this.lifecycleGeneration && this.settings.enabled) { this.startBackgroundManager(); @@ -104,13 +115,55 @@ export default class DynamicThemeBackgroundPlugin extends Plugin { async loadSettings() { const defaultSettings = getDefaultSettings(); const loaded: unknown = await this.loadData(); - this.settings = normalizeSettings(loaded, defaultSettings); + const normalized = normalizeSettings(loaded, defaultSettings); + + try { + const migration = migrateSettingsCredentials(normalized, this.app.secretStorage, sensitiveParamKeys); + this.settings = migration.settings; + if (migration.migrated) { + await this.saveData(this.settings); + } + } catch (error) { + this.settings = normalized; + new Notice(t("notice_credential_migration_failed"), 0); + logger.error("Wallpaper API credential migration failed; existing settings were not overwritten"); + throw error; + } } async saveSettings() { + assertSettingsCredentialsAreReferences(this.settings, sensitiveParamKeys); await this.saveData(this.settings); } + prepareWallpaperApiConfig(config: WallpaperApiConfig): WallpaperApiConfig { + return hydrateWallpaperApiConfig(config, this.app.secretStorage); + } + + async createWallpaperApi(config: WallpaperApiConfig, activate = config.enabled): Promise { + let runtimeConfig: WallpaperApiConfig; + try { + runtimeConfig = this.prepareWallpaperApiConfig(config); + } catch (error) { + await apiManager.deleteApi(config.id); + const message = + error instanceof MissingSecretReferenceError + ? t("notice_api_secret_missing") + : t("notice_api_secret_unavailable"); + apiManager.stateManager.notify(config.id, { + configEnabled: false, + instanceEnabled: false, + isLoading: false, + error: message, + }); + logger.warn("A wallpaper API was not created because its credentials are unavailable"); + return false; + } + + await apiManager.createApi(runtimeConfig, activate); + return apiManager.getApiById(config.id) !== undefined; + } + // ============================================================================ // 背景管理器代理 // ============================================================================ @@ -301,7 +354,7 @@ export default class DynamicThemeBackgroundPlugin extends Plugin { refreshActiveSettings() { this.settingTabs.forEach((tab) => { if (tab.isActive()) { - displayImperativeSettings(tab); + tab.refresh(); } }); this.events.emit("settings:changed", { key: "enabled", value: this.settings.enabled }); diff --git a/src/settings/sections/api-settings.ts b/src/settings/sections/api-settings.ts index 5562e68..9147bf0 100644 --- a/src/settings/sections/api-settings.ts +++ b/src/settings/sections/api-settings.ts @@ -15,6 +15,7 @@ import { WallpaperApiConfig, WallpaperApiType, apiManager, + apiRegistry, } from "../../wallpaper-apis"; export class ApiSettingsSection { @@ -70,7 +71,7 @@ export class ApiSettingsSection { .addExtraButton((button) => { button.setIcon("refresh-cw"); button.setTooltip(t("restore_default_apis_tooltip")); - button.onClick(() => { + button.onClick(async () => { // 重新生成默认设置以获取最新的默认 API const defaultApis = this.defaultSettings.wallpaperApis; @@ -80,12 +81,12 @@ export class ApiSettingsSection { if (!existingApi) { // 如果不存在,则添加并创建 API 实例 this.plugin.settings.wallpaperApis.push(apiConfig); - void apiManager.createApi(apiConfig, this.plugin.settings.enabled); + await this.plugin.createWallpaperApi(apiConfig, this.plugin.settings.enabled); } } new Notice(t("restore_default_apis_success")); - void this.plugin.saveSettings(); + await this.plugin.saveSettings(); this.display(this.container); }); }); @@ -126,10 +127,14 @@ export class ApiSettingsSection { const apiInstance = apiManager.getApiById(apiConfig.id); if (!apiInstance) { logger.warn(`API instance not found for ${apiConfig.name}`); - return; } - const setting = new Setting(container).setName(apiConfig.name).setDesc(apiInstance.getDescription()); + const description = + apiInstance?.getDescription() ?? + apiConfig.description ?? + apiRegistry.getDefaultDescription(apiConfig.type) ?? + t("notice_api_secret_unavailable"); + const setting = new Setting(container).setName(apiConfig.name).setDesc(description); // 在设置项的控件区域直接添加类型标签 setting.controlEl.createSpan({ text: apiConfig.type ?? "Unknown", cls: "dtb-badge" }); @@ -141,7 +146,11 @@ export class ApiSettingsSection { const statusDot = statusIndicator.createDiv("dtb-api-status-dot"); const statusText = statusIndicator.createSpan(); // 根据API的启用状态设置初始状态 - if (apiInstance.getEnabled()) { + if (!apiInstance) { + statusDot.addClass("error"); + statusText.textContent = t("status_error"); + statusText.title = t("notice_api_secret_unavailable"); + } else if (apiInstance.getEnabled()) { statusDot.addClass("enabled"); statusText.textContent = t("status_enabled"); } else { @@ -153,7 +162,7 @@ export class ApiSettingsSection { let toggleComponent: { setValue: (value: boolean) => void; getValue: () => boolean } | null = null; setting.addToggle((toggle) => { toggleComponent = toggle; // 保存 toggle 引用 - const toggleEl = toggle.setValue(apiInstance.getEnabled()); + const toggleEl = toggle.setValue(apiInstance?.getEnabled() ?? false).setDisabled(!apiInstance); // 使用智能API管理方法 toggleEl.onChange(async (value) => { @@ -243,8 +252,9 @@ export class ApiSettingsSection { button .setButtonText(t("button_add")) .setTooltip(t("add_api_bg_tooltip")) + .setDisabled(!apiInstance) .onClick(async () => { - await this.fetchWallpaperFromApi(apiInstance); + if (apiInstance) await this.fetchWallpaperFromApi(apiInstance); }) ) .addButton((button) => @@ -288,12 +298,11 @@ export class ApiSettingsSection { params: {}, }; - const modal = new WallpaperApiEditorModal(this.plugin.app, emptyConfig, (apiConfig) => { - // 创建新的API实例 - void apiManager.createApi(apiConfig, this.plugin.settings.enabled); + const modal = new WallpaperApiEditorModal(this.plugin, emptyConfig, async (apiConfig) => { // 添加到插件设置中 this.plugin.settings.wallpaperApis.push(apiConfig); - void this.plugin.saveSettings(); + await this.plugin.saveSettings(); + await this.plugin.createWallpaperApi(apiConfig, this.plugin.settings.enabled); // 这里仅需刷新 api 列表 this.displayWallpaperApis(); }); @@ -303,12 +312,11 @@ export class ApiSettingsSection { // 显示编辑壁纸API的模态窗口 private showEditWallpaperApiModal(apiConfig: WallpaperApiConfig, index: number) { - const modal = new WallpaperApiEditorModal(this.plugin.app, apiConfig, (updatedConfig) => { - // 有可能api类型也修改了,干脆重新创建API实例覆盖原来的 - void apiManager.createApi(updatedConfig, this.plugin.settings.enabled); - + const modal = new WallpaperApiEditorModal(this.plugin, apiConfig, async (updatedConfig) => { this.plugin.settings.wallpaperApis[index] = updatedConfig; - void this.plugin.saveSettings(); + await this.plugin.saveSettings(); + // 有可能api类型也修改了,重新创建API实例覆盖原来的 + await this.plugin.createWallpaperApi(updatedConfig, this.plugin.settings.enabled); // 这里仅需刷新 api 列表 this.displayWallpaperApis(); }); diff --git a/src/settings/settings-tab.ts b/src/settings/settings-tab.ts index d7b581d..32985af 100644 --- a/src/settings/settings-tab.ts +++ b/src/settings/settings-tab.ts @@ -3,7 +3,9 @@ * 协调各设置区块的展示和刷新 */ import { App, PluginSettingTab } from "obsidian"; +import type { Setting, SettingDefinitionItem } from "obsidian"; +import { displayImperativeSettings, refreshDualSettings } from "../core/obsidian-compat"; import { getDefaultSettings } from "../default-settings"; import { t } from "../i18n"; import type DynamicThemeBackgroundPlugin from "../plugin"; @@ -18,6 +20,7 @@ export class DTBSettingTab extends PluginSettingTab { private componentId: string; private active!: boolean; + private declarativeActive = false; // 设置区块 private basicSection?: BasicSettingsSection; @@ -46,11 +49,46 @@ export class DTBSettingTab extends PluginSettingTab { hide(): void { this.cleanup(); this.active = false; + this.declarativeActive = false; this.plugin.settingTabs.delete(this.componentId); } + getSettingDefinitions(): SettingDefinitionItem[] { + return [ + { + name: t("settings_title"), + searchable: false, + render: (setting) => { + this.activateSurface(true); + setting.settingEl.empty(); + this.displayHeader(setting.settingEl); + }, + }, + { + type: "page", + name: t("basic_settings_title"), + items: [this.basicSettingsDefinition()], + }, + { + type: "page", + name: t("mode_settings_title"), + items: [this.modeSettingsDefinition()], + }, + { + type: "page", + name: t("bg_management_title"), + items: [this.backgroundSettingsDefinition()], + }, + { + type: "page", + name: t("wallpaper_api_management_title"), + items: [this.apiSettingsDefinition()], + }, + ]; + } + display(): void { - this.active = true; + this.activateSurface(false); this.cleanup(); const { containerEl } = this; @@ -87,6 +125,14 @@ export class DTBSettingTab extends PluginSettingTab { this.apiSection.display(apiEl); } + refresh(): void { + if (this.declarativeActive) { + refreshDualSettings(this); + } else { + displayImperativeSettings(this); + } + } + // ============================================================================ // 针对性刷新(plugin.ts 调用) // ============================================================================ @@ -107,6 +153,85 @@ export class DTBSettingTab extends PluginSettingTab { // 内部方法 // ============================================================================ + private activateSurface(declarative: boolean): void { + this.active = true; + this.declarativeActive = declarative; + this.plugin.settingTabs.set(this.componentId, this); + } + + private basicSettingsDefinition(): SettingDefinitionItem { + return { + name: t("basic_settings_title"), + searchable: false, + render: (setting: Setting) => { + setting.settingEl.empty(); + const section = new BasicSettingsSection(this.plugin, this.defaultSettings); + this.basicSection = section; + section.display(setting.settingEl); + return () => { + section.cleanup(); + if (this.basicSection === section) this.basicSection = undefined; + }; + }, + }; + } + + private modeSettingsDefinition(): SettingDefinitionItem { + return { + name: t("mode_settings_title"), + searchable: false, + render: (setting: Setting) => { + setting.settingEl.empty(); + const section = new ModeSettingsSection(this.plugin, this.defaultSettings); + this.modeSection = section; + section.display(setting.settingEl); + return () => { + section.cleanup(); + if (this.modeSection === section) this.modeSection = undefined; + }; + }, + }; + } + + private backgroundSettingsDefinition(): SettingDefinitionItem { + return { + name: t("bg_management_title"), + searchable: false, + render: (setting: Setting) => { + setting.settingEl.empty(); + const section = new BgManagementSection(this.plugin, this.defaultSettings, { + onChanged: () => this.modeSection?.displayTimeRules(), + }); + this.bgSection = section; + section.display(setting.settingEl); + return () => { + section.cleanup(); + if (this.bgSection === section) this.bgSection = undefined; + }; + }, + }; + } + + private apiSettingsDefinition(): SettingDefinitionItem { + return { + name: t("wallpaper_api_management_title"), + searchable: false, + render: (setting: Setting) => { + setting.settingEl.empty(); + const section = new ApiSettingsSection(this.plugin, this.defaultSettings, { + onBackgroundsChanged: () => this.bgSection?.displayBackgrounds(), + onTimeRulesChanged: () => this.modeSection?.displayTimeRules(), + }); + this.apiSection = section; + section.display(setting.settingEl); + return () => { + section.cleanup(); + if (this.apiSection === section) this.apiSection = undefined; + }; + }, + }; + } + private displayHeader(containerEl: HTMLElement) { const headerContainer = containerEl.createDiv("dtb-section-header"); diff --git a/src/types.ts b/src/types.ts index a973089..915819a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -6,6 +6,7 @@ import type { WallpaperApiConfig } from "./wallpaper-apis"; export interface DTBSettings { + credentialStorageVersion: number; enabled: boolean; statusBarEnabled: boolean; // 是否激活状态栏 diff --git a/src/wallpaper-apis/core/types.ts b/src/wallpaper-apis/core/types.ts index 91d6d5d..bad2360 100644 --- a/src/wallpaper-apis/core/types.ts +++ b/src/wallpaper-apis/core/types.ts @@ -76,6 +76,15 @@ export interface WallpaperApiParams { [key: string]: ApiValueType; } +/** + * SecretStorage IDs for values that are hydrated into a short-lived runtime config. + * The referenced secret values must never be persisted in this object. + */ +export interface WallpaperApiSecretRefs { + params?: Record; + headers?: Record; +} + /** * 壁纸图片接口 */ @@ -103,8 +112,9 @@ export interface WallpaperApiConfig { description?: string; // API实例描述 baseUrl: string; // API基础域名或服务地址 (如: https://wallhaven.cc/api/v1) endpoints?: WallpaperApiEndpoints; // 具体的端点配置,如果不提供则使用默认端点 - headers?: Record; // 请求头 + headers?: Record; // 仅用于迁移旧数据和运行时请求;不得保存到 data.json params: WallpaperApiParams; + secretRefs?: WallpaperApiSecretRefs; // 持久化的 SecretStorage 引用 // 自定义设置 customSettings?: { diff --git a/styles.css b/styles.css index ad64707..3ce679b 100644 --- a/styles.css +++ b/styles.css @@ -334,6 +334,10 @@ flex: 1; margin-bottom: 0; } +.dtb-secret-control { + flex: 1; + min-width: 0; +} /* 通用按钮容器样式 */ .dtb-flex-container-end { diff --git a/tests/credential-storage.test.ts b/tests/credential-storage.test.ts new file mode 100644 index 0000000..ae53cde --- /dev/null +++ b/tests/credential-storage.test.ts @@ -0,0 +1,220 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + CURRENT_CREDENTIAL_STORAGE_VERSION, + CredentialMigrationError, + MissingSecretReferenceError, + assertSettingsCredentialsAreReferences, + hydrateWallpaperApiConfig, + migrateSettingsCredentials, +} from "../src/core/credential-storage"; +import type { SecretStore, SensitiveParamResolver } from "../src/core/credential-storage"; +import type { DTBSettings } from "../src/types"; +import { WallpaperApiType } from "../src/wallpaper-apis/core/types"; + +class FakeSecretStore implements SecretStore { + readonly values = new Map(); + setCalls = 0; + failOnSetCall?: number; + + getSecret(id: string): string | null { + return this.values.get(id) ?? null; + } + + listSecrets(): string[] { + return Array.from(this.values.keys()); + } + + setSecret(id: string, secret: string): void { + this.setCalls += 1; + if (this.setCalls === this.failOnSetCall) { + throw new Error("simulated SecretStorage failure"); + } + this.values.set(id, secret); + } +} + +const sensitiveParams: SensitiveParamResolver = (config) => + config.type === WallpaperApiType.Unsplash ? ["client_id"] : []; + +function settingsFixture(): DTBSettings { + return { + credentialStorageVersion: 0, + enabled: true, + statusBarEnabled: true, + blurDepth: 0, + brightness4Bg: 0.9, + saturate4Bg: 1, + bgColorLight: "#fff", + bgColorOpacityLight: 0.3, + bgColorDark: "#000", + bgColorOpacityDark: 0.4, + bgSize: "intelligent", + mode: "manual", + timeRules: [], + intervalMinutes: 60, + localBackgroundFolder: "", + backgrounds: [], + currentIndex: 0, + enableRandomWallpaper: true, + wallpaperApis: [ + { + id: "My Unsplash/API", + type: WallpaperApiType.Unsplash, + enabled: false, + name: "Unsplash", + baseUrl: "https://api.unsplash.com", + params: { client_id: "provider-secret", query: "mountains" }, + headers: { + Authorization: "Bearer header-secret", + "X-Trace": "private-header-value", + }, + }, + ], + }; +} + +void test("credential migration replaces password parameters and every custom header with references", () => { + const source = settingsFixture(); + const store = new FakeSecretStore(); + + const result = migrateSettingsCredentials(source, store, sensitiveParams); + const migrated = result.settings.wallpaperApis[0]; + const serialized = JSON.stringify(result.settings); + + assert.equal(result.migrated, true); + assert.equal(result.settings.credentialStorageVersion, CURRENT_CREDENTIAL_STORAGE_VERSION); + assert.deepEqual(migrated.params, { query: "mountains" }); + assert.equal(migrated.headers, undefined); + assert.ok(migrated.secretRefs?.params?.client_id); + assert.ok(migrated.secretRefs?.headers?.Authorization); + assert.ok(migrated.secretRefs?.headers?.["X-Trace"]); + for (const id of store.listSecrets()) { + assert.match(id, /^[a-z0-9]+(?:-[a-z0-9]+)*$/u); + } + assert.doesNotMatch(serialized, /provider-secret|header-secret|private-header-value/u); + assert.equal(source.wallpaperApis[0].params.client_id, "provider-secret"); + assert.equal(source.wallpaperApis[0].headers?.Authorization, "Bearer header-secret"); + assertSettingsCredentialsAreReferences(result.settings, sensitiveParams); +}); + +void test("credential migration is idempotent and reuses an occupied ID only for the same value", () => { + const store = new FakeSecretStore(); + store.values.set("dynamic-theme-background-my-unsplash-api-param-client-id", "another-user-secret"); + + const first = migrateSettingsCredentials(settingsFixture(), store, sensitiveParams); + const firstRef = first.settings.wallpaperApis[0].secretRefs?.params?.client_id; + const callsAfterFirst = store.setCalls; + const second = migrateSettingsCredentials(first.settings, store, sensitiveParams); + + assert.notEqual(firstRef, "dynamic-theme-background-my-unsplash-api-param-client-id"); + assert.match(firstRef ?? "", /-2$/u); + assert.equal(store.getSecret(firstRef ?? ""), "provider-secret"); + assert.equal(second.migrated, false); + assert.deepEqual(second.settings, first.settings); + assert.equal(store.setCalls, callsAfterFirst); +}); + +void test("a failed SecretStorage write leaves the persisted source fully recoverable", () => { + const source = settingsFixture(); + const before = JSON.stringify(source); + const store = new FakeSecretStore(); + store.failOnSetCall = 2; + + assert.throws( + () => migrateSettingsCredentials(source, store, sensitiveParams), + (error: unknown) => error instanceof CredentialMigrationError + ); + assert.equal(JSON.stringify(source), before); + assert.equal(source.credentialStorageVersion, 0); +}); + +void test("a partial write can be retried without creating duplicate secret IDs", () => { + const source = settingsFixture(); + const store = new FakeSecretStore(); + store.failOnSetCall = 2; + + assert.throws(() => migrateSettingsCredentials(source, store, sensitiveParams), CredentialMigrationError); + store.failOnSetCall = undefined; + const retried = migrateSettingsCredentials(source, store, sensitiveParams); + + assert.equal( + retried.settings.wallpaperApis[0].secretRefs?.params?.client_id, + "dynamic-theme-background-my-unsplash-api-param-client-id" + ); + assert.equal(store.listSecrets().some((id) => /param-client-id-2$/u.test(id)), false); + assertSettingsCredentialsAreReferences(retried.settings, sensitiveParams); +}); + +void test("storage failures and reference conflicts reject generically without exposing credential values", () => { + const source = settingsFixture(); + const unavailableStore: SecretStore = { + getSecret: () => { + throw new Error("provider-secret"); + }, + setSecret: () => undefined, + }; + + assert.throws( + () => migrateSettingsCredentials(source, unavailableStore, sensitiveParams), + (error: unknown) => + error instanceof CredentialMigrationError && !error.message.includes("provider-secret") + ); + + const conflictStore = new FakeSecretStore(); + conflictStore.values.set("existing-reference", "different-value"); + source.wallpaperApis[0].secretRefs = { params: { client_id: "existing-reference" } }; + const before = JSON.stringify(source); + assert.throws( + () => migrateSettingsCredentials(source, conflictStore, sensitiveParams), + (error: unknown) => + error instanceof CredentialMigrationError && !error.message.includes("provider-secret") + ); + assert.equal(JSON.stringify(source), before); +}); + +void test("already-sanitized settings do not read SecretStorage during load", () => { + const initialStore = new FakeSecretStore(); + const sanitized = migrateSettingsCredentials(settingsFixture(), initialStore, sensitiveParams).settings; + const noEnumerationStore: SecretStore = { + getSecret: () => { + throw new Error("getSecret should not run during an idempotent migration"); + }, + setSecret: () => { + throw new Error("setSecret should not run during an idempotent migration"); + }, + }; + + const result = migrateSettingsCredentials(sanitized, noEnumerationStore, sensitiveParams); + + assert.equal(result.migrated, false); + assert.deepEqual(result.settings, sanitized); +}); + +void test("runtime hydration returns an isolated config and fails closed for missing references", () => { + const store = new FakeSecretStore(); + const migrated = migrateSettingsCredentials(settingsFixture(), store, sensitiveParams).settings.wallpaperApis[0]; + const hydrated = hydrateWallpaperApiConfig(migrated, store); + + assert.equal(hydrated.params.client_id, "provider-secret"); + assert.equal(hydrated.headers?.Authorization, "Bearer header-secret"); + assert.equal(hydrated.headers?.["X-Trace"], "private-header-value"); + assert.notEqual(hydrated, migrated); + assert.equal(migrated.params.client_id, undefined); + assert.equal(migrated.headers, undefined); + + const missingRef = migrated.secretRefs?.params?.client_id; + assert.ok(missingRef); + store.values.delete(missingRef); + assert.throws( + () => hydrateWallpaperApiConfig(migrated, store), + (error: unknown) => error instanceof MissingSecretReferenceError + ); +}); + +void test("the persistence guard rejects both legacy password fields and custom headers", () => { + const source = settingsFixture(); + + assert.throws(() => assertSettingsCredentialsAreReferences(source, sensitiveParams), /plaintext credentials/u); +}); diff --git a/tests/lifecycle.test.ts b/tests/lifecycle.test.ts index 2e8c06c..bde3892 100644 --- a/tests/lifecycle.test.ts +++ b/tests/lifecycle.test.ts @@ -7,7 +7,8 @@ void test("runtime startup is layout-ready, ordered, and generation guarded", () const apiManager = readFileSync("src/wallpaper-apis/core/api-manager.ts", "utf8"); assert.match(plugin, /onLayoutReady\(\(\) =>/u); - assert.match(plugin, /await apiManager\.createApi\(apiConfig, false\)/u); + assert.match(plugin, /await this\.createWallpaperApi\(apiConfig, false\)/u); + assert.match(plugin, /await apiManager\.createApi\(runtimeConfig, activate\)/u); assert.match(plugin, /await apiManager\.activateConfiguredApis\(\)/u); assert.match(plugin, /generation !== this\.startGeneration/u); assert.match(plugin, /void apiManager\.suspendAllApis\(\)/u); diff --git a/tests/release-contract.test.ts b/tests/release-contract.test.ts index cf5204d..66f1b78 100644 --- a/tests/release-contract.test.ts +++ b/tests/release-contract.test.ts @@ -19,6 +19,7 @@ void test("release CI installs the lockfile and runs the shared gate first", () void test("version authorities and mobile compatibility stay aligned", () => { const packageJson = JSON.parse(readFileSync("package.json", "utf8")) as { + devDependencies?: Record; version: string; }; const manifest = JSON.parse(readFileSync("manifest.json", "utf8")) as { @@ -31,7 +32,8 @@ void test("version authorities and mobile compatibility stay aligned", () => { assert.equal(packageJson.version, manifest.version); assert.match(versionSource, new RegExp(`"${manifest.version}"`, "u")); assert.equal(manifest.isDesktopOnly, false); - assert.equal(manifest.minAppVersion, "1.7.2"); + assert.equal(manifest.minAppVersion, "1.11.4"); + assert.equal(packageJson.devDependencies?.obsidian, "^1.13.1"); assert.match(readFileSync(".gitignore", "utf8"), /^main\.js$/mu); }); @@ -49,7 +51,8 @@ void test("both READMEs disclose network, credential-storage, and telemetry boun for (const path of ["README.md", "README.zh.md"]) { const readme = readFileSync(path, "utf8"); assert.match(readme, /data\.json/u); - assert.match(readme, /Obsidian 1\.7\.2/u); + assert.match(readme, /Obsidian 1\.11\.4/u); + assert.match(readme, /SecretStorage/u); assert.match(readme, /telemetry|遥测/u); assert.match(readme, /third party|第三方/u); } diff --git a/tests/settings-migration-contract.test.ts b/tests/settings-migration-contract.test.ts new file mode 100644 index 0000000..29ad977 --- /dev/null +++ b/tests/settings-migration-contract.test.ts @@ -0,0 +1,33 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +void test("plugin migration and runtime creation cross the SecretStorage boundary", () => { + const plugin = readFileSync("src/plugin.ts", "utf8"); + + assert.match(plugin, /this\.app\.secretStorage/u); + assert.match(plugin, /migrateSettingsCredentials/u); + assert.match(plugin, /hydrateWallpaperApiConfig/u); + assert.match(plugin, /assertSettingsCredentialsAreReferences/u); + assert.match(plugin, /createWallpaperApi/u); +}); + +void test("settings use declarative definitions with an imperative compatibility fallback", () => { + const settingsTab = readFileSync("src/settings/settings-tab.ts", "utf8"); + const settingsView = readFileSync("src/settings/settings-view.ts", "utf8"); + + assert.match(settingsTab, /getSettingDefinitions\(\)/u); + assert.match(settingsTab, /type:\s*"page"/u); + assert.match(settingsTab, /display\(\): void/u); + assert.match(settingsTab, /activateSurface\(declarative: boolean\)/u); + assert.match(settingsTab, /this\.plugin\.settingTabs\.set\(this\.componentId, this\)/u); + assert.match(settingsView, /displayImperativeSettings/u); +}); + +void test("credential controls store SecretStorage references instead of plaintext values", () => { + const modal = readFileSync("src/modals/wallpaper-api-modal.ts", "utf8"); + + assert.match(modal, /SecretComponent/u); + assert.match(modal, /secretRefs/u); + assert.doesNotMatch(modal, /\(input as HTMLInputElement\)\.type = "password"/u); +}); diff --git a/tests/settings.test.ts b/tests/settings.test.ts index 6306eb6..4ca81be 100644 --- a/tests/settings.test.ts +++ b/tests/settings.test.ts @@ -6,6 +6,7 @@ import type { DTBSettings } from "../src/types"; function settingsFixture(): DTBSettings { return { + credentialStorageVersion: 1, enabled: true, statusBarEnabled: true, blurDepth: 0, From 52d5120803d4c39759c28b3636034d90b991606e Mon Sep 17 00:00:00 2001 From: sean2077 <37324769+sean2077@users.noreply.github.com> Date: Tue, 21 Jul 2026 12:57:01 +0800 Subject: [PATCH 2/3] fix: prevent settings layout clipping --- styles.css | 69 +++++++++++++++++++--- tests/responsive-settings-contract.test.ts | 29 +++++++++ 2 files changed, 89 insertions(+), 9 deletions(-) create mode 100644 tests/responsive-settings-contract.test.ts diff --git a/styles.css b/styles.css index 3ce679b..93e321f 100644 --- a/styles.css +++ b/styles.css @@ -169,8 +169,8 @@ position: relative; overflow: hidden; display: flex !important; - flex-direction: row !important; - align-items: flex-start !important; + flex-direction: row; + align-items: flex-start; gap: 1rem !important; box-shadow: var(--dtb-shadow-base); } @@ -366,13 +366,6 @@ /* 响应式设计 (Responsive Design) */ @media (max-width: 600px) { - .dtb-item { - flex-direction: column; - align-items: flex-start; - gap: 0.6rem; - padding: 0.8rem; - } - .dtb-flex-container, .dtb-flex-container-center, .dtb-flex-container-spaced { @@ -1062,6 +1055,64 @@ margin-bottom: 12px; } +@media (max-width: 1100px) { + .dtb-item, + .dtb-section-container .setting-item { + flex-direction: column; + align-items: stretch; + gap: 0.6rem !important; + padding: 0.8rem !important; + } + + .dtb-section-container .setting-item .setting-item-info { + margin-right: 0; + } + + .dtb-section-container .setting-item .setting-item-control { + flex-wrap: wrap; + justify-content: flex-start; + width: 100%; + } + + .dtb-bg-content { + flex-wrap: wrap; + width: 100%; + min-width: 0; + } + + .dtb-bg-content > .dtb-button-container { + flex: 1 0 100%; + justify-content: flex-end; + } + + .dtb-bg-name { + min-width: 0; + overflow-wrap: anywhere; + } +} + +@media (max-width: 600px) { + .dtb-section-header { + flex-wrap: wrap; + justify-content: flex-start; + gap: 8px; + } + + .dtb-links { + flex-wrap: wrap; + width: 100%; + min-width: 0; + } + + .dtb-bg-content { + flex-direction: column; + align-items: stretch; + width: 100%; + min-width: 0; + gap: 0.75rem; + } +} + /* ===== 炫酷状态栏图标样式 ===== */ .dtb-status-bar { display: flex; diff --git a/tests/responsive-settings-contract.test.ts b/tests/responsive-settings-contract.test.ts new file mode 100644 index 0000000..7f37794 --- /dev/null +++ b/tests/responsive-settings-contract.test.ts @@ -0,0 +1,29 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +const styles = readFileSync("styles.css", "utf8"); + +void test("constrained settings override the desktop card direction", () => { + assert.match( + styles, + /@media \(max-width: 1100px\) \{\s*\.dtb-item,\s*\.dtb-section-container \.setting-item \{\s*flex-direction: column;\s*align-items: stretch;/u + ); + assert.match(styles, /\.dtb-bg-name \{\s*min-width: 0;\s*overflow-wrap: anywhere;/u); + assert.match( + styles, + /\.dtb-bg-content > \.dtb-button-container \{\s*flex: 1 0 100%;\s*justify-content: flex-end;/u + ); +}); + +void test("narrow settings wrap headers and stack background content", () => { + assert.match( + styles, + /\.dtb-section-header \{\s*flex-wrap: wrap;\s*justify-content: flex-start;\s*gap: 8px;/u + ); + assert.match(styles, /\.dtb-links \{\s*flex-wrap: wrap;\s*width: 100%;\s*min-width: 0;/u); + assert.match( + styles, + /\.dtb-bg-content \{\s*flex-direction: column;\s*align-items: stretch;\s*width: 100%;\s*min-width: 0;/u + ); +}); From f2b5ddd6107e6c01a4d66c436a56c2ff4d9e4ad5 Mon Sep 17 00:00:00 2001 From: sean2077 <37324769+sean2077@users.noreply.github.com> Date: Tue, 21 Jul 2026 13:57:32 +0800 Subject: [PATCH 3/3] fix: support accessible settings reordering --- src/i18n/en.ts | 8 +- src/i18n/zh-cn.ts | 8 +- src/settings/sections/api-settings.ts | 13 +- src/settings/sections/bg-management.ts | 23 ++-- src/settings/sections/mode-settings.ts | 21 ++-- src/utils/drag-sort.ts | 123 +++++++++++-------- styles.css | 15 ++- tests/drag-sort.test.ts | 70 +++++++++++ tests/reorder-accessibility-contract.test.ts | 32 +++++ tests/responsive-settings-contract.test.ts | 4 +- 10 files changed, 230 insertions(+), 87 deletions(-) create mode 100644 tests/drag-sort.test.ts create mode 100644 tests/reorder-accessibility-contract.test.ts diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 54eb1dd..cb0ce4c 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -68,7 +68,7 @@ export default { reset_time_rules_button: "Restore defaults", reset_time_rules_tooltip: "Reset the time slot rules to their default settings.", time_rule_hint: - "💡 When time slot rules overlap, the first matching rule takes precedence. The indicator 🔥 means the current active rule.", + "💡 Drag rules or use the up/down buttons to reorder them. When rules overlap, the first match wins; 🔥 marks the active rule.", rule_name_label: "Rule name:", start_time_label: "Start time (hh:mm):", end_time_label: "End time (hh:mm):", @@ -106,8 +106,10 @@ export default { gradient_css_label: "CSS gradient, for example linear-gradient(45deg, #ff0000, #0000ff):", select_background_option: "Select background", background_management_hint: - "💡 Tip: you can drag background items to reorder them; the background with 🔥 indicator is the currently applied background.", + "💡 Drag backgrounds or use the up/down buttons to reorder them; 🔥 marks the currently applied background.", drag_handle_tooltip: "Drag to reorder", + move_item_up: "Move up", + move_item_down: "Move down", random_wallpaper_settings_title: "Random wallpaper", enable_random_wallpaper_name: "Enable random wallpaper", enable_random_wallpaper_desc: @@ -125,7 +127,7 @@ export default { "Restore the default wallpaper APIs provided by the plugin (will not overwrite existing APIs)", restore_default_apis_success: "🎉 Successfully restored default wallpaper APIs", wallpaper_api_hint: - "💡 You can add multiple API instances of the same type with different parameters to get different types of wallpapers.", + "💡 Add multiple API instances with different parameters; drag them or use the up/down buttons to reorder them.", add_api_bg_tooltip: "Click to add a new wallpaper from API", wallpaper_api_url_name: "Wallpaper API URL", wallpaper_api_url_desc: diff --git a/src/i18n/zh-cn.ts b/src/i18n/zh-cn.ts index 7c3482d..8b6cc79 100644 --- a/src/i18n/zh-cn.ts +++ b/src/i18n/zh-cn.ts @@ -60,7 +60,7 @@ export default { clear_time_rules_tooltip: "清除所有时段规则", reset_time_rules_button: "恢复默认", reset_time_rules_tooltip: "将时段规则重置为默认设置", - time_rule_hint: "💡 当时段规则存在重叠,优先使用第一个匹配的规则;带有 🔥 标识的规则为当前应用的规则", + time_rule_hint: "💡 可拖拽或使用上移/下移按钮排序;规则重叠时优先使用第一个匹配项,🔥 表示当前规则", rule_name_label: "规则名称:", start_time_label: "开始时间(HH:MM):", end_time_label: "结束时间(HH:MM):", @@ -97,8 +97,10 @@ export default { color_value_label: "颜色值(如 #ff0000):", gradient_css_label: "CSS 渐变(如 linear-gradient(45deg, #ff0000, #0000ff)):", select_background_option: "选择背景", - background_management_hint: "💡 提示:您可以拖拽背景项目来重新排序;带有 🔥 标识的背景为当前应用的背景", + background_management_hint: "💡 可拖拽或使用上移/下移按钮排序背景;🔥 表示当前应用的背景", drag_handle_tooltip: "拖拽以重新排序", + move_item_up: "上移", + move_item_down: "下移", random_wallpaper_settings_title: "随机壁纸设置", enable_random_wallpaper_name: "启用随机壁纸", enable_random_wallpaper_desc: "启用后,将从壁纸网站 API 和背景列表中获取随机壁纸,否则将从背景列表按顺序获取", @@ -113,7 +115,7 @@ export default { add_api_button: "添加 API", restore_default_apis_tooltip: "恢复插件提供的默认壁纸 API(不会覆盖现有的 API)", restore_default_apis_success: "🎉 成功恢复默认壁纸 API", - wallpaper_api_hint: "💡 您可以创建同一种类型的 API 但参数不同的多个 API 实例,以获取不同类型的壁纸", + wallpaper_api_hint: "💡 可创建参数不同的多个 API 实例,并通过拖拽或上移/下移按钮排序", add_api_bg_tooltip: "点击从 API 添加新的壁纸", wallpaper_api_url_name: "壁纸 API 地址", wallpaper_api_url_desc: "支持 Unsplash、Pixabay、Pexels 等 API,默认使用 Unsplash API(需要替换 YOUR_ACCESS_KEY)", diff --git a/src/settings/sections/api-settings.ts b/src/settings/sections/api-settings.ts index 9147bf0..59ddf4c 100644 --- a/src/settings/sections/api-settings.ts +++ b/src/settings/sections/api-settings.ts @@ -109,18 +109,18 @@ export class ApiSettingsSection { container.empty(); // 初始化 API 拖拽排序 - this.apiDragSort = new DragSort({ + const apiDragSort = new DragSort({ container, items: this.plugin.settings.wallpaperApis, getItemId: (api) => api.id, - itemClass: "dtb-draggable", - idDataAttribute: "apiId", + reorderLabels: { up: t("move_item_up"), down: t("move_item_down") }, onReorder: async (reorderedApis) => { this.plugin.settings.wallpaperApis = reorderedApis; await this.plugin.saveSettings(); this.displayWallpaperApis(); }, }); + this.apiDragSort = apiDragSort; // API 列表 this.plugin.settings.wallpaperApis.forEach((apiConfig: WallpaperApiConfig, index: number) => { @@ -275,15 +275,10 @@ export class ApiSettingsSection { }) ); - // 设置拖拽属性 - setting.settingEl.addClass("dtb-draggable"); - setting.settingEl.dataset.apiId = apiConfig.id; - // 添加通用条目样式类 setting.settingEl.addClass("dtb-button-container"); // 按钮样式 - // 启用拖拽功能 - this.apiDragSort?.enableDragForElement(setting.settingEl, apiConfig); + apiDragSort.enableDragForElement(setting.settingEl, apiConfig, setting.controlEl); }); } diff --git a/src/settings/sections/bg-management.ts b/src/settings/sections/bg-management.ts index e3d60d6..282e734 100644 --- a/src/settings/sections/bg-management.ts +++ b/src/settings/sections/bg-management.ts @@ -106,27 +106,29 @@ export class BgManagementSection { container.empty(); // 初始化背景拖拽排序 - this.backgroundDragSort = new DragSort({ + const backgroundDragSort = new DragSort({ container, items: this.plugin.settings.backgrounds, getItemId: (bg) => bg.id, - itemClass: "dtb-draggable", - idDataAttribute: "bgId", + reorderLabels: { up: t("move_item_up"), down: t("move_item_down") }, onReorder: async (reorderedBackgrounds) => { + const activeBackgroundId = this.plugin.background?.id; this.plugin.settings.backgrounds = reorderedBackgrounds; + if (activeBackgroundId) { + const activeIndex = reorderedBackgrounds.findIndex( + (background) => background.id === activeBackgroundId + ); + if (activeIndex >= 0) this.plugin.settings.currentIndex = activeIndex; + } await this.plugin.saveSettings(); // 这里仅需刷新背景列表和时间规则列表 this.displayBackgrounds(); this.onChanged?.(); }, }); + this.backgroundDragSort = backgroundDragSort; this.plugin.settings.backgrounds.forEach((bg: BackgroundItem, index: number) => { - const bgEl = container.createDiv("dtb-item dtb-draggable"); - - // 添加拖拽相关属性 - bgEl.draggable = true; - bgEl.dataset.bgId = bg.id; - bgEl.dataset.index = index.toString(); + const bgEl = container.createDiv("dtb-item"); // 添加拖拽手柄 const dragHandle = bgEl.createDiv("dtb-drag-handle"); @@ -185,8 +187,7 @@ export class BgManagementSection { this.onChanged?.(); }; - // 启用拖拽功能 - this.backgroundDragSort?.enableDragForElement(bgEl, bg); + backgroundDragSort.enableDragForElement(bgEl, bg, actions); }); } diff --git a/src/settings/sections/mode-settings.ts b/src/settings/sections/mode-settings.ts index c85b953..1ff8d7f 100644 --- a/src/settings/sections/mode-settings.ts +++ b/src/settings/sections/mode-settings.ts @@ -3,7 +3,12 @@ import { t } from "../../i18n"; import { ConfirmModal, TimeRuleModal } from "../../modals"; import type DynamicThemeBackgroundPlugin from "../../plugin"; import type { DTBSettings, TimeRule } from "../../types"; -import { DragSort, addDropdownTooltip, addEnhancedDropdownTooltip, generateId } from "../../utils"; +import { + DragSort, + addDropdownTooltip, + addEnhancedDropdownTooltip, + generateId, +} from "../../utils"; export class ModeSettingsSection { private plugin: DynamicThemeBackgroundPlugin; @@ -143,18 +148,19 @@ export class ModeSettingsSection { container.empty(); // 初始化时间规则拖拽排序 - this.timeRuleDragSort = new DragSort({ + const timeRuleDragSort = new DragSort({ container, items: this.plugin.settings.timeRules, getItemId: (rule) => rule.id, - itemClass: "dtb-draggable", - idDataAttribute: "ruleId", + reorderLabels: { up: t("move_item_up"), down: t("move_item_down") }, onReorder: async (reorderedRules) => { this.plugin.settings.timeRules = reorderedRules; await this.plugin.saveSettings(); + this.plugin.startBackgroundManager(); this.displayTimeRules(); }, }); + this.timeRuleDragSort = timeRuleDragSort; // 获取当前激活的时间规则 const activeRule = this.plugin.getCurrentTimeRule(); @@ -217,15 +223,10 @@ export class ModeSettingsSection { }) ); - // 设置拖拽属性 - setting.settingEl.addClass("dtb-draggable"); - setting.settingEl.dataset.ruleId = rule.id; - // 添加通用条目样式类 setting.settingEl.addClass("dtb-button-container"); // 按钮样式 - // 启用拖拽功能 - this.timeRuleDragSort?.enableDragForElement(setting.settingEl, rule); + timeRuleDragSort.enableDragForElement(setting.settingEl, rule, setting.controlEl); }); } diff --git a/src/utils/drag-sort.ts b/src/utils/drag-sort.ts index 90844ea..7263ce5 100644 --- a/src/utils/drag-sort.ts +++ b/src/utils/drag-sort.ts @@ -5,6 +5,8 @@ import { logger } from "../core/logger"; +const DRAGGABLE_CLASS = "dtb-draggable"; + /** * 拖拽排序配置接口 */ @@ -15,62 +17,40 @@ export interface DragSortConfig { items: T[]; /** 获取项目唯一ID的函数 */ getItemId: (item: T) => string; - /** 拖拽项目类名 */ - itemClass?: string; - /** ID数据属性名 */ - idDataAttribute?: string; + /** Accessible labels for optional keyboard/touch reorder buttons. */ + reorderLabels?: { up: string; down: string }; /** 排序完成后的回调函数 */ onReorder: (items: T[]) => Promise | void; - /** 拖拽开始时的回调函数 */ - onDragStart?: (item: T) => void; - /** 拖拽结束时的回调函数 */ - onDragEnd?: (item: T) => void; } +export type ReorderDirection = -1 | 1; + /** * 通用拖拽排序工具类 */ export class DragSort { private config: DragSortConfig; private dragHandles: WeakMap void> = new WeakMap(); + private reorderPending = false; constructor(config: DragSortConfig) { - this.config = { - itemClass: "dtb-draggable", - idDataAttribute: "itemId", - ...config, - }; + this.config = config; } /** * 为指定元素添加拖拽功能 */ - public enableDragForElement(element: HTMLElement, item: T): void { + public enableDragForElement(element: HTMLElement, item: T, reorderControls?: HTMLElement): void { const itemId = this.config.getItemId(item); // 设置拖拽属性 element.draggable = true; - if (this.config.itemClass) { - element.classList.add(this.config.itemClass); - } - if (this.config.idDataAttribute) { - element.dataset[this.config.idDataAttribute] = itemId; - } + element.classList.add(DRAGGABLE_CLASS); + element.dataset.itemId = itemId; // 添加拖拽事件监听器 this.addDragListeners(element, item); - } - - /** - * 为容器内的所有项目启用拖拽功能 - */ - public enableDragForAllItems(): void { - const elements = this.config.container.querySelectorAll(`.${this.config.itemClass}`); - elements.forEach((element, index) => { - if (index < this.config.items.length) { - this.enableDragForElement(element as HTMLElement, this.config.items[index]); - } - }); + if (reorderControls) this.addReorderControls(reorderControls, item); } /** @@ -78,9 +58,7 @@ export class DragSort { */ public disableDragForElement(element: HTMLElement): void { element.draggable = false; - if (this.config.itemClass) { - element.classList.remove(this.config.itemClass); - } + element.classList.remove(DRAGGABLE_CLASS); // 移除事件监听器(WeakMap 自动清理引用,无需手动 delete) const cleanup = this.dragHandles.get(element); @@ -93,17 +71,29 @@ export class DragSort { * 禁用所有拖拽功能 */ public disableAllDrag(): void { - const elements = this.config.container.querySelectorAll(`.${this.config.itemClass}`); + const elements = this.config.container.querySelectorAll(`.${DRAGGABLE_CLASS}`); elements.forEach((element) => { this.disableDragForElement(element as HTMLElement); }); } - /** - * 更新配置 - */ - public updateConfig(newConfig: Partial>): void { - this.config = { ...this.config, ...newConfig }; + /** Return whether an item can move one position in the requested direction. */ + public canMove(item: T, direction: ReorderDirection): boolean { + if (this.reorderPending) return false; + const index = this.findItemIndex(item); + const targetIndex = index + direction; + return index >= 0 && targetIndex >= 0 && targetIndex < this.config.items.length; + } + + /** Move an item one position without requiring pointer drag support. */ + public async moveItem(item: T, direction: ReorderDirection): Promise { + if (!this.canMove(item, direction)) return false; + + const items = [...this.config.items]; + const currentIndex = this.findItemIndex(item); + const [movedItem] = items.splice(currentIndex, 1); + items.splice(currentIndex + direction, 0, movedItem); + return this.commitReorder(items); } /** @@ -116,17 +106,15 @@ export class DragSort { e.dataTransfer.setData("text/plain", this.config.getItemId(item)); element.classList.add("dtb-dragging"); } - this.config.onDragStart?.(item); }; const dragEndHandler = () => { element.classList.remove("dtb-dragging"); // 移除所有拖拽相关的样式 - const allItems = this.config.container.querySelectorAll(`.${this.config.itemClass}`); + const allItems = this.config.container.querySelectorAll(`.${DRAGGABLE_CLASS}`); allItems?.forEach((item) => { item.classList.remove("dtb-drag-over", "dtb-drag-over-top", "dtb-drag-over-bottom"); }); - this.config.onDragEnd?.(item); }; const dragOverHandler = (e: DragEvent) => { @@ -162,7 +150,7 @@ export class DragSort { e.preventDefault(); const draggedId = e.dataTransfer?.getData("text/plain"); - const targetId = this.config.idDataAttribute ? element.dataset[this.config.idDataAttribute] : undefined; + const targetId = element.dataset.itemId; if (!draggedId || !targetId || draggedId === targetId) { return; @@ -231,10 +219,47 @@ export class DragSort { // 插入到新位置 items.splice(newTargetIndex, 0, draggedItem); - // 更新配置中的项目列表 - this.config.items.splice(0, this.config.items.length, ...items); + await this.commitReorder(items); + } - // 调用重排序回调 - await this.config.onReorder(items); + private findItemIndex(item: T): number { + const itemId = this.config.getItemId(item); + return this.config.items.findIndex((candidate) => this.config.getItemId(candidate) === itemId); + } + + private addReorderControls(container: HTMLElement, item: T): void { + const labels = this.config.reorderLabels; + if (!labels) return; + for (const direction of [-1, 1] as const) { + const label = direction < 0 ? labels.up : labels.down; + const button = container.createEl("button", { + text: direction < 0 ? "↑" : "↓", + cls: "dtb-reorder-button", + attr: { "aria-label": label }, + }); + button.disabled = !this.canMove(item, direction); + button.onclick = () => { + void this.moveItem(item, direction).catch((error) => + logger.error("Reorder", error) + ); + }; + } + } + + private async commitReorder(items: T[]): Promise { + if (this.reorderPending) return false; + + const previousItems = [...this.config.items]; + this.reorderPending = true; + this.config.items.splice(0, this.config.items.length, ...items); + try { + await this.config.onReorder([...items]); + return true; + } catch (error) { + this.config.items.splice(0, this.config.items.length, ...previousItems); + throw error; + } finally { + this.reorderPending = false; + } } } diff --git a/styles.css b/styles.css index 93e321f..fc3592a 100644 --- a/styles.css +++ b/styles.css @@ -592,6 +592,11 @@ display: flex; gap: 0.6rem; } + +.dtb-reorder-button { + flex: 0 0 auto; + touch-action: manipulation; +} /* 通用操作按钮 */ .dtb-button, .dtb-button-container button { @@ -1056,6 +1061,8 @@ } @media (max-width: 1100px) { + :has(> .dtb-hint) > .setting-item, + .dtb-large-button-container .setting-item, .dtb-item, .dtb-section-container .setting-item { flex-direction: column; @@ -1064,10 +1071,14 @@ padding: 0.8rem !important; } + :has(> .dtb-hint) > .setting-item .setting-item-info, + .dtb-large-button-container .setting-item .setting-item-info, .dtb-section-container .setting-item .setting-item-info { margin-right: 0; } + :has(> .dtb-hint) > .setting-item .setting-item-control, + .dtb-large-button-container .setting-item .setting-item-control, .dtb-section-container .setting-item .setting-item-control { flex-wrap: wrap; justify-content: flex-start; @@ -1191,7 +1202,9 @@ .dtb-large-button, .dtb-large-button-container button, .dtb-link, - .dtb-remove-button { + .dtb-remove-button, + .dtb-button-container .dtb-reorder-button, + .dtb-reorder-button { min-width: 44px; min-height: 44px; } diff --git a/tests/drag-sort.test.ts b/tests/drag-sort.test.ts new file mode 100644 index 0000000..e01b701 --- /dev/null +++ b/tests/drag-sort.test.ts @@ -0,0 +1,70 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { DragSort } from "../src/utils/drag-sort"; + +interface Item { + id: string; +} + +function createSort(items: Item[], onReorder: (reordered: Item[]) => Promise | void): DragSort { + return new DragSort({ + container: {} as HTMLElement, + items, + getItemId: (item) => item.id, + onReorder, + }); +} + +void test("accessible reorder moves one position and reports boundaries", async () => { + const items = [{ id: "a" }, { id: "b" }, { id: "c" }]; + const observed: string[][] = []; + const sort = createSort(items, (reordered) => { + observed.push(reordered.map((item) => item.id)); + }); + + assert.equal(sort.canMove(items[0], -1), false); + assert.equal(sort.canMove(items[0], 1), true); + assert.equal(sort.canMove(items[2], 1), false); + assert.equal(await sort.moveItem(items[1], -1), true); + assert.deepEqual( + items.map((item) => item.id), + ["b", "a", "c"] + ); + assert.deepEqual(observed, [["b", "a", "c"]]); + assert.equal(await sort.moveItem(items[0], -1), false); + assert.equal(observed.length, 1); +}); + +void test("accessible reorder ignores duplicate actions while persistence is pending", async () => { + const items = [{ id: "a" }, { id: "b" }, { id: "c" }]; + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const sort = createSort(items, () => gate); + + const firstMove = sort.moveItem(items[0], 1); + await Promise.resolve(); + assert.equal(sort.canMove(items[2], -1), false); + assert.equal(await sort.moveItem(items[2], -1), false); + + release(); + assert.equal(await firstMove, true); + assert.deepEqual( + items.map((item) => item.id), + ["b", "a", "c"] + ); +}); + +void test("accessible reorder restores its local order when persistence rejects", async () => { + const items = [{ id: "a" }, { id: "b" }]; + const sort = createSort(items, () => Promise.reject(new Error("save failed"))); + + await assert.rejects(sort.moveItem(items[0], 1), /save failed/u); + assert.deepEqual( + items.map((item) => item.id), + ["a", "b"] + ); + assert.equal(sort.canMove(items[0], 1), true); +}); diff --git a/tests/reorder-accessibility-contract.test.ts b/tests/reorder-accessibility-contract.test.ts new file mode 100644 index 0000000..bc32a9d --- /dev/null +++ b/tests/reorder-accessibility-contract.test.ts @@ -0,0 +1,32 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +void test("all sortable setting lists expose shared native reorder controls", () => { + const dragSort = readFileSync("src/utils/drag-sort.ts", "utf8"); + const sections = [ + "src/settings/sections/bg-management.ts", + "src/settings/sections/mode-settings.ts", + "src/settings/sections/api-settings.ts", + ]; + + for (const path of sections) { + assert.match(readFileSync(path, "utf8"), /enableDragForElement\([^,]+,\s*[^,]+,\s*[^)]+\)/u, path); + } + assert.match(dragSort, /container\.createEl\("button"/u); + assert.match(dragSort, /"aria-label": label/u); + assert.match(dragSort, /button\.disabled = !this\.canMove\(item, direction\)/u); +}); + +void test("reorder controls retain focus and coarse-pointer target contracts", () => { + const styles = readFileSync("styles.css", "utf8"); + const en = readFileSync("src/i18n/en.ts", "utf8"); + const zh = readFileSync("src/i18n/zh-cn.ts", "utf8"); + + assert.match(styles, /\.dtb-reorder-button \{\s*flex: 0 0 auto;\s*touch-action: manipulation;/u); + assert.match(styles, /@media \(pointer: coarse\)[\s\S]*\.dtb-button-container \.dtb-reorder-button/u); + for (const locale of [en, zh]) { + assert.match(locale, /move_item_up:/u); + assert.match(locale, /move_item_down:/u); + } +}); diff --git a/tests/responsive-settings-contract.test.ts b/tests/responsive-settings-contract.test.ts index 7f37794..a99a50b 100644 --- a/tests/responsive-settings-contract.test.ts +++ b/tests/responsive-settings-contract.test.ts @@ -7,8 +7,10 @@ const styles = readFileSync("styles.css", "utf8"); void test("constrained settings override the desktop card direction", () => { assert.match( styles, - /@media \(max-width: 1100px\) \{\s*\.dtb-item,\s*\.dtb-section-container \.setting-item \{\s*flex-direction: column;\s*align-items: stretch;/u + /@media \(max-width: 1100px\) \{\s*:has\(> \.dtb-hint\) > \.setting-item,\s*\.dtb-large-button-container \.setting-item,\s*\.dtb-item,\s*\.dtb-section-container \.setting-item \{\s*flex-direction: column;\s*align-items: stretch;/u ); + assert.match(styles, /:has\(> \.dtb-hint\) > \.setting-item,/u); + assert.match(styles, /\.dtb-large-button-container \.setting-item,/u); assert.match(styles, /\.dtb-bg-name \{\s*min-width: 0;\s*overflow-wrap: anywhere;/u); assert.match( styles,