Merge branch 'feat/settings-secret-migration'

This commit is contained in:
sean2077 2026-07-21 14:04:13 +08:00
commit affeda7894
No known key found for this signature in database
GPG key ID: AE28E02B67FB7191
32 changed files with 1201 additions and 172 deletions

View file

@ -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.

View file

@ -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.

View file

@ -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 相对目录。

View file

@ -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.41.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.

View file

@ -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",

2
package-lock.json generated
View file

@ -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",

View file

@ -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",

View file

@ -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, string>
): 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, string>
): 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<string, string>();
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<string, ApiValueType> | Record<string, string>,
refs: Record<string, string> | 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");
}
}
}

View file

@ -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";

View file

@ -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();
}
}

View file

@ -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<T>(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),

View file

@ -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, // 是否激活状态栏

View file

@ -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:
@ -218,10 +220,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 +245,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.",
// ===== 状态文本 =====

View file

@ -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",
@ -201,10 +203,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 +228,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} 张壁纸。",
// ===== 状态文本 =====

View file

@ -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<void>;
// 基础配置输入元素
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<string, HTMLElement> = new Map();
secretParamRefs: Map<string, string> = new Map();
// 额外参数输入
extraParamsTextarea!: HTMLTextAreaElement;
@ -42,8 +45,13 @@ export class WallpaperApiEditorModal extends Modal {
// 自定义设置输入元素
customSettingsInputs: Map<string, HTMLElement> = new Map();
constructor(app: App, apiConfig: WallpaperApiConfig, onSubmit: (apiConfig: WallpaperApiConfig) => void) {
super(app);
constructor(
plugin: DynamicThemeBackgroundPlugin,
apiConfig: WallpaperApiConfig,
onSubmit: (apiConfig: WallpaperApiConfig) => void | Promise<void>
) {
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<string, string | number | boolean | string[]> = {};
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<string, string> = {};
this.headerInputs.forEach(({ key, value }) => {
if (key.value.trim() && value.value.trim()) {
headers[key.value.trim()] = value.value.trim();
// 收集 SecretStorage 引用;请求头值本身不会进入配置对象
const headerSecretRefs: Record<string, string> = {};
this.headerInputs.forEach(({ key, secretId }) => {
if (key.value.trim() && secretId) {
headerSecretRefs[key.value.trim()] = secretId;
}
});
const paramSecretRefs: Record<string, string> = {};
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<void> {
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;

View file

@ -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<void> {
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<boolean> {
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 });

View file

@ -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);
});
});
@ -108,28 +109,32 @@ export class ApiSettingsSection {
container.empty();
// 初始化 API 拖拽排序
this.apiDragSort = new DragSort<WallpaperApiConfig>({
const apiDragSort = new DragSort<WallpaperApiConfig>({
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) => {
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) =>
@ -265,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);
});
}
@ -288,12 +293,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 +307,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();
});

View file

@ -106,27 +106,29 @@ export class BgManagementSection {
container.empty();
// 初始化背景拖拽排序
this.backgroundDragSort = new DragSort<BackgroundItem>({
const backgroundDragSort = new DragSort<BackgroundItem>({
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);
});
}

View file

@ -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<TimeRule>({
const timeRuleDragSort = new DragSort<TimeRule>({
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);
});
}

View file

@ -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");

View file

@ -6,6 +6,7 @@
import type { WallpaperApiConfig } from "./wallpaper-apis";
export interface DTBSettings {
credentialStorageVersion: number;
enabled: boolean;
statusBarEnabled: boolean; // 是否激活状态栏

View file

@ -5,6 +5,8 @@
import { logger } from "../core/logger";
const DRAGGABLE_CLASS = "dtb-draggable";
/**
*
*/
@ -15,62 +17,40 @@ export interface DragSortConfig<T> {
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> | void;
/** 拖拽开始时的回调函数 */
onDragStart?: (item: T) => void;
/** 拖拽结束时的回调函数 */
onDragEnd?: (item: T) => void;
}
export type ReorderDirection = -1 | 1;
/**
*
*/
export class DragSort<T> {
private config: DragSortConfig<T>;
private dragHandles: WeakMap<HTMLElement, () => void> = new WeakMap();
private reorderPending = false;
constructor(config: DragSortConfig<T>) {
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<T> {
*/
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<T> {
*
*/
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<DragSortConfig<T>>): 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<boolean> {
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<T> {
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<T> {
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<T> {
// 插入到新位置
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<boolean> {
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;
}
}
}

View file

@ -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<string, string>;
headers?: Record<string, string>;
}
/**
*
*/
@ -103,8 +112,9 @@ export interface WallpaperApiConfig {
description?: string; // API实例描述
baseUrl: string; // API基础域名或服务地址 (如: https://wallhaven.cc/api/v1)
endpoints?: WallpaperApiEndpoints; // 具体的端点配置,如果不提供则使用默认端点
headers?: Record<string, string>; // 请求头
headers?: Record<string, string>; // 仅用于迁移旧数据和运行时请求;不得保存到 data.json
params: WallpaperApiParams;
secretRefs?: WallpaperApiSecretRefs; // 持久化的 SecretStorage 引用
// 自定义设置
customSettings?: {

View file

@ -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);
}
@ -334,6 +334,10 @@
flex: 1;
margin-bottom: 0;
}
.dtb-secret-control {
flex: 1;
min-width: 0;
}
/* 通用按钮容器样式 */
.dtb-flex-container-end {
@ -362,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 {
@ -595,6 +592,11 @@
display: flex;
gap: 0.6rem;
}
.dtb-reorder-button {
flex: 0 0 auto;
touch-action: manipulation;
}
/* 通用操作按钮 */
.dtb-button,
.dtb-button-container button {
@ -1058,6 +1060,70 @@
margin-bottom: 12px;
}
@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;
align-items: stretch;
gap: 0.6rem !important;
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;
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;
@ -1136,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;
}

View file

@ -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<string, string>();
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);
});

70
tests/drag-sort.test.ts Normal file
View file

@ -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> | void): DragSort<Item> {
return new DragSort<Item>({
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<void>((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);
});

View file

@ -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);

View file

@ -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<string, string>;
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);
}

View file

@ -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);
}
});

View file

@ -0,0 +1,31 @@
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*: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,
/\.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
);
});

View file

@ -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);
});

View file

@ -6,6 +6,7 @@ import type { DTBSettings } from "../src/types";
function settingsFixture(): DTBSettings {
return {
credentialStorageVersion: 1,
enabled: true,
statusBarEnabled: true,
blurDepth: 0,