diff --git a/.github/actions/build-rust/action.yml b/.github/actions/build-rust/action.yml index 0a9bb6dba0..305897d211 100644 --- a/.github/actions/build-rust/action.yml +++ b/.github/actions/build-rust/action.yml @@ -59,13 +59,20 @@ runs: echo "TARGET_CC=clang -D_BSD_SOURCE" >> "$GITHUB_ENV" fi + - name: Prepare cache key + id: cache-key + shell: bash + run: | + shared_key="$(printf '%s' "${{ inputs.target }}-${{ inputs.package }}" | tr -c 'A-Za-z0-9_.-' '-')" + echo "shared-key=$shared_key" >> "$GITHUB_OUTPUT" + - name: Cache cargo uses: Swatinem/rust-cache@v2 if: ${{ runner.os == 'Windows' }} with: workspaces: ${{ env.DEV_DRIVE_WORKSPACE }} save-if: ${{ github.ref_name == 'canary' }} - shared-key: ${{ inputs.target }}-${{ inputs.package }} + shared-key: ${{ steps.cache-key.outputs.shared-key }} env: CARGO_HOME: ${{ env.DEV_DRIVE }}/.cargo RUSTUP_HOME: ${{ env.DEV_DRIVE }}/.rustup @@ -75,7 +82,7 @@ runs: if: ${{ runner.os != 'Windows' }} with: save-if: ${{ github.ref_name == 'canary' }} - shared-key: ${{ inputs.target }}-${{ inputs.package }} + shared-key: ${{ steps.cache-key.outputs.shared-key }} - name: Build shell: bash diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index 4baacc4632..d5d4f59e86 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -135,6 +135,159 @@ jobs: echo "All changes are submitted" fi + mobile-native-build-filter: + name: Mobile native build filter + runs-on: ubuntu-latest + outputs: + run-android: ${{ steps.mobile-native-filter.outputs.android }} + run-ios: ${{ steps.mobile-native-filter.outputs.ios }} + steps: + - uses: actions/checkout@v6 + + - uses: dorny/paths-filter@v3 + id: mobile-native-filter + with: + filters: | + android: + - '.github/workflows/build-test.yml' + - 'packages/frontend/apps/android/**' + - 'packages/frontend/mobile-native/**' + - '.cargo/**' + - 'Cargo.lock' + - 'Cargo.toml' + - 'rust-toolchain*' + ios: + - '.github/workflows/build-test.yml' + - 'packages/frontend/apps/ios/**' + - 'packages/frontend/mobile-native/**' + - '.cargo/**' + - 'Cargo.lock' + - 'Cargo.toml' + - 'rust-toolchain*' + + build-android-app: + name: Build Android app + if: ${{ needs.mobile-native-build-filter.outputs.run-android == 'true' }} + runs-on: ubuntu-latest + needs: + - mobile-native-build-filter + steps: + - uses: actions/checkout@v6 + + - name: Setup Node.js + uses: ./.github/actions/setup-node + with: + extra-flags: workspaces focus @affine/monorepo @affine-tools/cli @affine/android + electron-install: false + + - uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '21' + cache: 'gradle' + + - name: Setup Rust + uses: ./.github/actions/build-rust + with: + target: 'aarch64-linux-android' + package: 'affine_mobile_native' + no-build: 'true' + + - name: Build Android web assets + run: yarn affine @affine/android build + env: + PUBLIC_PATH: '/' + + - name: Write CI Firebase config + run: | + cat > packages/frontend/apps/android/App/app/google-services.json <<'JSON' + { + "project_info": { + "project_number": "1", + "project_id": "affine-ci", + "storage_bucket": "affine-ci.appspot.com" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:1:android:0000000000000000", + "android_client_info": { + "package_name": "app.affine.pro" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "ci-placeholder" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + } + ], + "configuration_version": "1" + } + JSON + + - name: Cap sync + run: yarn workspace @affine/android cap sync + + - name: Build Android debug app + working-directory: packages/frontend/apps/android/App + run: ./gradlew :app:assembleCanaryDebug --no-daemon --stacktrace + + build-ios-app: + name: Build iOS app + if: ${{ needs.mobile-native-build-filter.outputs.run-ios == 'true' }} + runs-on: macos-15 + needs: + - mobile-native-build-filter + steps: + - uses: actions/checkout@v6 + + - name: Setup Node.js + uses: ./.github/actions/setup-node + with: + extra-flags: workspaces focus @affine/monorepo @affine-tools/cli @affine/ios + electron-install: false + hard-link-nm: false + + - uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: 26.2 + + - name: Setup Rust + uses: ./.github/actions/build-rust + with: + target: 'aarch64-apple-ios-sim' + package: 'affine_mobile_native' + no-build: 'true' + + - name: Build iOS web assets + run: yarn affine @affine/ios build + env: + PUBLIC_PATH: '/' + + - name: Cap sync + run: yarn workspace @affine/ios sync + + - name: Build iOS simulator app + run: | + xcodebuild \ + -workspace packages/frontend/apps/ios/App/App.xcworkspace \ + -scheme App \ + -configuration Debug \ + -sdk iphonesimulator \ + -destination 'generic/platform=iOS Simulator' \ + ARCHS=arm64 \ + ONLY_ACTIVE_ARCH=YES \ + CODE_SIGNING_ALLOWED=NO \ + CODE_SIGNING_REQUIRED=NO \ + build + rust-test-filter: name: Rust test filter runs-on: ubuntu-latest @@ -1328,6 +1481,9 @@ jobs: - analyze - lint - typecheck + - mobile-native-build-filter + - build-android-app + - build-ios-app - lint-rust - check-git-status - check-yarn-binary diff --git a/packages/frontend/apps/android/App/app/build.gradle b/packages/frontend/apps/android/App/app/build.gradle index 22d2f62f2e..822b3cdadd 100644 --- a/packages/frontend/apps/android/App/app/build.gradle +++ b/packages/frontend/apps/android/App/app/build.gradle @@ -27,6 +27,7 @@ android { versionCode 1 versionName System.getenv('VERSION_NAME') ?: 'v1.0.0-local' testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + manifestPlaceholders = [usesCleartextTraffic: "false"] aaptOptions { // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. // Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61 @@ -49,6 +50,7 @@ android { debug { minifyEnabled false debuggable true + manifestPlaceholders = [usesCleartextTraffic: "true"] } } flavorDimensions = ['chanel'] diff --git a/packages/frontend/apps/android/App/app/src/main/AndroidManifest.xml b/packages/frontend/apps/android/App/app/src/main/AndroidManifest.xml index 99d01294d1..2c6ab32bae 100644 --- a/packages/frontend/apps/android/App/app/src/main/AndroidManifest.xml +++ b/packages/frontend/apps/android/App/app/src/main/AndroidManifest.xml @@ -13,14 +13,16 @@ android:icon="@mipmap/ic_launcher" android:label="@string/app_name" android:roundIcon="@mipmap/ic_launcher_round" + android:hardwareAccelerated="true" android:supportsRtl="true" - android:usesCleartextTraffic="true" + android:usesCleartextTraffic="${usesCleartextTraffic}" android:theme="@style/AppTheme"> diff --git a/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/MainActivity.kt b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/MainActivity.kt index 2f75eb5acf..8fa79b2ac2 100644 --- a/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/MainActivity.kt +++ b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/MainActivity.kt @@ -93,7 +93,27 @@ class MainActivity : BridgeActivity(), AIButtonPlugin.Callback, AFFiNEThemePlugi override fun load() { super.load() AuthInitializer.initialize(bridge) - bridge.webView.settings.mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW + configureEditorWebView() + } + + private fun configureEditorWebView() { + bridge.webView.apply { + overScrollMode = View.OVER_SCROLL_NEVER + isHorizontalScrollBarEnabled = false + isVerticalScrollBarEnabled = false + settings.apply { + // Debug builds may point CAP_SERVER_URL at an HTTP dev server; release builds + // should keep mixed content blocked. + mixedContentMode = if (BuildConfig.DEBUG) { + WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE + } else { + WebSettings.MIXED_CONTENT_NEVER_ALLOW + } + setSupportZoom(false) + builtInZoomControls = false + displayZoomControls = false + } + } } override fun present() { diff --git a/packages/frontend/apps/android/capacitor.config.ts b/packages/frontend/apps/android/capacitor.config.ts index fc761f0ce5..50a645d19d 100644 --- a/packages/frontend/apps/android/capacitor.config.ts +++ b/packages/frontend/apps/android/capacitor.config.ts @@ -7,6 +7,9 @@ const packageJson = JSON.parse( readFileSync(resolve(__dirname, './package.json'), 'utf-8') ); +const capServerUrl = process.env.CAP_SERVER_URL; +const allowsCleartextServer = capServerUrl?.startsWith('http://') ?? false; + interface AppConfig { affineVersion: string; } @@ -27,9 +30,6 @@ const config: CapacitorConfig & AppConfig = { }, adjustMarginsForEdgeToEdge: 'force', }, - server: { - cleartext: true, - }, plugins: { CapacitorHttp: { enabled: false, @@ -40,10 +40,11 @@ const config: CapacitorConfig & AppConfig = { }, }; -if (process.env.CAP_SERVER_URL) { +if (capServerUrl) { Object.assign(config, { server: { - url: process.env.CAP_SERVER_URL, + url: capServerUrl, + cleartext: allowsCleartextServer, }, }); } diff --git a/packages/frontend/apps/android/src/setup.ts b/packages/frontend/apps/android/src/setup.ts index 36544281fb..0710353291 100644 --- a/packages/frontend/apps/android/src/setup.ts +++ b/packages/frontend/apps/android/src/setup.ts @@ -2,3 +2,21 @@ import '@affine/core/bootstrap/browser'; import '@affine/component/theme'; import '@affine/core/mobile/styles/mobile.css'; import './proxy'; + +import { viewportRuntimeConfig } from '@blocksuite/affine/std/gfx'; + +// Android WebView is less prone to the iOS WKWebView process kill, so keep the +// user-facing zoom range unchanged. These knobs only reduce gesture-time work +// and low-zoom canvas memory while preserving the normal-zoom render quality. +viewportRuntimeConfig.VIEWPORT_REFRESH_PIXEL_THRESHOLD = 60; +viewportRuntimeConfig.VIEWPORT_REFRESH_MAX_INTERVAL = 300; +viewportRuntimeConfig.SKIP_REFRESH_DURING_GESTURE = true; +viewportRuntimeConfig.OVERSCAN_RATIO = 0.2; +viewportRuntimeConfig.OVERSCAN_RATIO_BLOCK = 0; +// Keep this aligned with iOS: the ~200ms gesture debounce plus this delay gives +// elements/connectors enough time to settle while still reappearing within ~500ms. +viewportRuntimeConfig.POST_GESTURE_REFRESH_DELAY = 220; +viewportRuntimeConfig.CANVAS_DPR_CAP_BY_ZOOM = [ + [0.5, 1], + [0.8, 2], +]; diff --git a/packages/frontend/apps/electron/test/main/byok-storage.spec.ts b/packages/frontend/apps/electron/test/main/byok-storage.spec.ts index be0c3c7c2e..8bbcd3e2ab 100644 --- a/packages/frontend/apps/electron/test/main/byok-storage.spec.ts +++ b/packages/frontend/apps/electron/test/main/byok-storage.spec.ts @@ -1,33 +1,62 @@ +import os from 'node:os'; import path from 'node:path'; import fs from 'fs-extra'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; -const tmpDir = path.join(__dirname, 'tmp-byok-storage'); +const electronMock = vi.hoisted(() => ({ + tmpDir: '', + appOn: vi.fn(), + isEncryptionAvailable: vi.fn(() => true), + encryptString: vi.fn((value: string) => Buffer.from(value, 'utf-8')), + decryptString: vi.fn((value: Buffer) => value.toString('utf-8')), +})); + let disposeWorkspaceByokStorage: (() => void) | undefined; vi.mock('electron', () => ({ app: { - getPath: () => tmpDir, - on: vi.fn(), + getPath: () => electronMock.tmpDir, + on: electronMock.appOn, }, safeStorage: { - isEncryptionAvailable: () => true, - encryptString: (value: string) => Buffer.from(value, 'utf-8'), - decryptString: (value: Buffer) => value.toString('utf-8'), + isEncryptionAvailable: electronMock.isEncryptionAvailable, + encryptString: electronMock.encryptString, + decryptString: electronMock.decryptString, + }, +})); + +vi.mock('../../src/main/logger', () => ({ + logger: { + error: vi.fn(), }, })); beforeEach(async () => { + vi.useRealTimers(); vi.resetModules(); + electronMock.appOn.mockReset(); + electronMock.isEncryptionAvailable.mockReset().mockReturnValue(true); + electronMock.encryptString + .mockReset() + .mockImplementation((value: string) => Buffer.from(value, 'utf-8')); + electronMock.decryptString + .mockReset() + .mockImplementation((value: Buffer) => value.toString('utf-8')); disposeWorkspaceByokStorage = undefined; - await fs.remove(tmpDir); + electronMock.tmpDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'affine-byok-storage-') + ); }); afterEach(async () => { disposeWorkspaceByokStorage?.(); + disposeWorkspaceByokStorage = undefined; vi.resetModules(); - await fs.remove(tmpDir); + if (electronMock.tmpDir) { + await fs.remove(electronMock.tmpDir); + } + electronMock.tmpDir = ''; }); describe('byok storage handlers', () => { @@ -84,6 +113,26 @@ describe('byok storage handlers', () => { ).resolves.toEqual([]); }); + test('does not write local keys when secure storage is unavailable', async () => { + electronMock.isEncryptionAvailable.mockReturnValue(false); + + const { byokStorageHandlers, disposeWorkspaceByokStorage: dispose } = + await import('@affine/electron/main/byok-storage/handlers'); + disposeWorkspaceByokStorage = dispose; + const ipcEvent = undefined; + + await expect(byokStorageHandlers.isSupported()).resolves.toBe(false); + await expect( + byokStorageHandlers.upsertWorkspaceKey(ipcEvent, 'workspace-1', { + id: 'local-openai', + provider: 'openai', + name: 'OpenAI', + apiKey: 'sk-openai', + }) + ).rejects.toThrow('Secure BYOK key storage is not available.'); + expect(electronMock.encryptString).not.toHaveBeenCalled(); + }); + test('preserves existing local key fields during partial updates', async () => { const { byokStorageHandlers, disposeWorkspaceByokStorage: dispose } = await import('@affine/electron/main/byok-storage/handlers');