Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/codeql/codeql-config.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# CodeQL 扫描配置。
#
# node_modules 里的 Capacitor Android 源码会被 Gradle 作为子项目一起编译,因此进入 Kotlin/Java
# 数据库并被扫描,报出的是第三方代码自身的问题(java/xss、java/error-message-exposure)。
# 依赖源码的问题交给依赖升级和 Dependabot 跟踪,不在这里重复报出。
name: "CFST-GUI CodeQL config"

paths-ignore:
- "node_modules/**"
2 changes: 2 additions & 0 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,8 @@ jobs:
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
# 排除 node_modules 等第三方源码,见配置文件内的说明。
config-file: ./.github/codeql/codeql-config.yml

# Android's Gradle project lives under mobile/android and needs the generated AAR.
- name: Build Android project for CodeQL
Expand Down
4 changes: 4 additions & 0 deletions docs/mobile/android-mobile.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,10 @@ Activity 使用 `adjustResize`;前端只通过 `visualViewport` 计算键盘 i

Android 原生层会关闭 theme force dark、WebView `FORCE_DARK_OFF` 和 Android 13+ algorithmic darkening,避免 WebView 或系统深色策略把按钮背景自动变淡、把按钮文字改成低对比颜色。

App 长时间留在后台后,系统在内存压力下可能只回收 WebView 渲染进程而保留 app 进程(常驻前台服务会先保住 app 进程)。`MainActivity` 处理 `WebViewClient.onRenderProcessGone` 并返回 true,因为默认行为会在渲染进程被系统杀掉时终止整个 app 进程,正在运行的探测任务会被任务快照标记成 `recovery_required`。原生侧只记录状态,等 app 回到前台时再 `recreate()` Activity,由新的 Capacitor bridge 和 WebView 重新加载前端;Go runtime、运行中的任务和事件序号保持不中断。

重建与冷启动期间的白色由同一底色的三层覆盖,并跟随前端主题。启动窗口底色来自启动主题的 `windowSplashScreenBackground`,它同时是 `Theme.SplashScreen` 里 `android:windowBackground` 的取值来源(`@drawable/compat_splash_screen_no_icon_background`),因此 Activity 窗口自建立起就没有白底;`recreate()` 在 resumed 状态触发,系统按 `preserveWindow` 保留窗口,间隙显示的是被保留窗口的背景而不是新启动窗口;`MainActivity.refreshSurfaceColors()` 在 `onCreate` 和主题切换时把窗口背景与 WebView 底色设为同一颜色,覆盖 index.html 首帧之前的空白。颜色取 `res/values/colors.xml` 的 `app_background`(`#F5F7FC`)与 `app_background_dark`(`#09090B`),与前端 `styles.css` 的 `--app-bg` 同步。主题的真实来源是前端配置:`frontend/src/App.vue` 每次解析出主题后调用 `frontend/src/lib/surfaceTheme.ts`,后者把模式写入 `localStorage` 供启动画面读取,并调用原生方法 `SetSurfaceTheme`;原生侧 `AndroidSurfaceTheme` 把模式缓存到 `SharedPreferences`,`MainActivity` 在 `super.onCreate` 之前用缓存值 `setTheme` 选出 `AppTheme.NoActionBarLaunch.Dark`,并在切换时立即刷新窗口与 WebView 底色,不需要重建 Activity。冷启动第一帧的系统启动窗口早于 app 代码运行、读不到前端缓存,只能跟随系统深色模式(`res/values-night/styles.xml` 提供深色版本):`auto_system_time` 档位下与运行期一致,前端强制浅色而系统为深色时首帧仍可能偏深。Vue 包加载与启动步骤这段时间由 `frontend/index.html` 内联的首帧启动画面覆盖(App 底色 + `/favicon.png`,`localStorage` 里记录的深色主题会让它取 `#09090b`,挂载后 `frontend/src/main.ts` 淡出移除,index.html 自带 10s 兜底),桌面端 / WebUI / Android 共用。启动主题同时把 `windowSplashScreenAnimatedIcon` 设为 `@mipmap/ic_launcher`,Android 12 及以上不再显示系统通用图标;未接入 `installSplashScreen()`,Android 11 及以下启动窗口仍只显示背景色。

Android 原生 select 在部分 WebView 中会显示为系统白色大面板;前端会在 Android app 环境拦截 `select` 的 pointer/touch/click 事件,改用应用内底部 picker。该 picker 支持点外关闭、滚动区域选择、Esc 关闭、禁用项和基础 `role=listbox/option` ARIA 状态。

## Notes
Expand Down
46 changes: 46 additions & 0 deletions frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,56 @@
<link rel="icon" type="image/png" href="/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>CFST-GUI</title>
<script>
// 首帧启动画面与页面底色跟随上次已知主题(src/lib/surfaceTheme.ts 写入同一个 key),
// 避免深色主题下先闪一帧浅色;真实主题由 src/App.vue 挂载后重新应用。
try {
if (window.localStorage.getItem("cfst.surface-theme") === "dark") {
document.documentElement.dataset.theme = "dark";
}
} catch {
// 存储不可用(隐私模式等)时保持默认浅色启动画面。
}
</script>
<style>
/* 首帧启动画面:内联样式,早于打包后的 CSS/JS 生效,让 WebView 重建和冷启动的等待
显示为 App 底色 + 图标,而不是一帧空白。挂载后由 src/main.ts 淡出移除,
下方兜底超时保证引导失败时不会永久遮挡界面。 */
#cfst-boot {
position: fixed;
inset: 0;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
background: #f5f7fc;
transition: opacity 0.18s ease;
}
html[data-theme="dark"] #cfst-boot {
background: #09090b;
}
#cfst-boot[data-hidden="true"] {
opacity: 0;
}
#cfst-boot img {
width: 64px;
height: 64px;
border-radius: 14px;
}
</style>
</head>

<body>
<div id="app"></div>
<div id="cfst-boot" aria-hidden="true">
<img src="/favicon.png" width="64" height="64" alt="" />
</div>
<script>
window.setTimeout(function () {
var boot = document.getElementById("cfst-boot");
if (boot) boot.remove();
}, 10000);
</script>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
2 changes: 2 additions & 0 deletions frontend/src/App.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<script setup vapor lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
import { WindowCenter, WindowGetSize, WindowIsMaximised, WindowMaximise, WindowSetSize, WindowUnfullscreen, WindowUnmaximise, isWailsRuntimeAvailable } from "./lib/wailsRuntime";
import { applySurfaceTheme } from "./lib/surfaceTheme";
import {
backupConfigToWebDAV,
checkForUpdates,
Expand Down Expand Up @@ -2331,6 +2332,7 @@ function applyThemeMode() {
const mode = resolvedThemeMode();
document.documentElement.dataset.theme = mode;
document.documentElement.classList.toggle("dark", mode === "dark");
applySurfaceTheme(mode);
}

function scheduleThemeRefresh() {
Expand Down
12 changes: 12 additions & 0 deletions frontend/src/lib/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,7 @@ interface CapacitorCfstPlugin {
GetAndroidRuntimeStatus?: () => Promise<unknown>;
Init: (payload?: Record<string, unknown>) => Promise<unknown>;
SetKeepAliveEnabled?: (payload: { enabled: boolean }) => Promise<unknown>;
SetSurfaceTheme?: (payload: { dark: boolean }) => Promise<unknown>;
OpenLogDirectory: (payload: Record<string, unknown>) => Promise<unknown>;
OpenPath: (payload: { targetPath: string }) => Promise<unknown>;
OpenBatteryOptimizationSettings?: (payload?: Record<string, unknown>) => Promise<unknown>;
Expand Down Expand Up @@ -1079,6 +1080,17 @@ export async function setKeepAliveEnabled(enabled: boolean) {
});
}

/** 把当前主题同步给 Android 原生窗口与 WebView 底色;其他运行环境直接跳过。 */
export async function setAndroidSurfaceTheme(dark: boolean): Promise<void> {
if (!shouldUseNativeBridge()) {
return;
}
await ensureNativeBridge();
if (typeof cfstNative.SetSurfaceTheme === "function") {
await cfstNative.SetSurfaceTheme({ dark });
}
}

export async function openBatteryOptimizationSettings(mode = "request") {
if (shouldUseNativeBridge()) {
await ensureNativeBridge();
Expand Down
58 changes: 58 additions & 0 deletions frontend/src/lib/surfaceTheme.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

const nativeSurface = vi.hoisted(() => ({ setAndroidSurfaceTheme: vi.fn(async () => undefined) }));

vi.mock("./bridge", () => ({
setAndroidSurfaceTheme: nativeSurface.setAndroidSurfaceTheme,
}));

function stubBrowserStorage() {
const store = new Map<string, string>();
vi.stubGlobal("window", {
localStorage: {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => {
store.set(key, value);
},
},
});
return store;
}

describe("applySurfaceTheme", () => {
beforeEach(() => {
nativeSurface.setAndroidSurfaceTheme.mockClear();
});

afterEach(() => {
vi.unstubAllGlobals();
vi.resetModules();
});

it("caches the resolved mode and notifies the Android surface only on change", async () => {
const store = stubBrowserStorage();
const { applySurfaceTheme } = await import("./surfaceTheme");

applySurfaceTheme("light");
applySurfaceTheme("light");

expect(nativeSurface.setAndroidSurfaceTheme).toHaveBeenCalledTimes(1);
expect(nativeSurface.setAndroidSurfaceTheme).toHaveBeenCalledWith(false);
expect(store.get("cfst.surface-theme")).toBe("light");

applySurfaceTheme("dark");

expect(nativeSurface.setAndroidSurfaceTheme).toHaveBeenCalledTimes(2);
expect(nativeSurface.setAndroidSurfaceTheme).toHaveBeenLastCalledWith(true);
expect(store.get("cfst.surface-theme")).toBe("dark");
});

it("keeps working when the page has no web storage", async () => {
vi.stubGlobal("window", {});
const { applySurfaceTheme } = await import("./surfaceTheme");

applySurfaceTheme("dark");

expect(nativeSurface.setAndroidSurfaceTheme).toHaveBeenCalledWith(true);
});
});
25 changes: 25 additions & 0 deletions frontend/src/lib/surfaceTheme.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { setAndroidSurfaceTheme } from "./bridge";

/**
* 把解析后的主题同步到 Vue 之外的两层界面:
* 1. `localStorage`:`index.html` 内联的启动画面在打包代码加载前读它,避免深色主题先闪一帧浅色;
* 2. Android 原生窗口与 WebView 底色(`SetSurfaceTheme`):覆盖 WebView 重建、冷启动和 relaunch 的空档。
* 只记录解析结果,不参与主题判断;浏览器 / WebUI / 桌面端第 2 步静默跳过。
*/
const STORAGE_KEY = "cfst.surface-theme";
export type SurfaceThemeMode = "light" | "dark";

let appliedMode: SurfaceThemeMode | null = null;

export function applySurfaceTheme(mode: SurfaceThemeMode): void {
if (mode === appliedMode) {
return;
}
appliedMode = mode;
try {
window.localStorage.setItem(STORAGE_KEY, mode);
} catch {
// 隐私模式等场景下存储不可用:只影响下次冷启动的首帧底色。
}
void setAndroidSurfaceTheme(mode === "dark").catch(() => undefined);
}
11 changes: 11 additions & 0 deletions frontend/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,19 @@ import "./styles.css";
// 先定通道再挂载:Wails 宿主的运行时是导航完成后才注入的,若在就绪前挂载,首批调用会
// 误判成没有宿主(历史上表现为桌面端右下角弹出「WebUI 请求失败 (404)」,版本号停在占位
// 值)。通道判定只在 Wails 宿主页面上等一次运行时注入,其余页面立即挂载且不发起网络请求。
// index.html 内联的首帧启动画面:挂载完成后淡出移除,失败时由 index.html 的兜底超时移除。
function dismissBootScreen(): void {
const boot = document.getElementById("cfst-boot");
if (!boot) {
return;
}
boot.dataset.hidden = "true";
window.setTimeout(() => boot.remove(), 240);
}

void resolveBridgeMode()
.catch(() => undefined)
.then(() => {
createVaporApp(App).use(vaporInteropPlugin).mount("#app");
dismissBootScreen();
});
Loading
Loading