From 2bb230364a2b2bd31decc626e8c17a916ed30386 Mon Sep 17 00:00:00 2001 From: mac Date: Fri, 31 Jul 2026 19:00:19 +0800 Subject: [PATCH 1/3] ci: publish PR web previews --- .github/workflows/pr-preview.yml | 58 ++++++++++++++++++++++++++++++++ frontend/app.config.js | 13 +++++++ 2 files changed, 71 insertions(+) create mode 100644 .github/workflows/pr-preview.yml create mode 100644 frontend/app.config.js diff --git a/.github/workflows/pr-preview.yml b/.github/workflows/pr-preview.yml new file mode 100644 index 0000000..f981060 --- /dev/null +++ b/.github/workflows/pr-preview.yml @@ -0,0 +1,58 @@ +name: PR Preview + +on: + pull_request: + types: [opened, reopened, synchronize, closed] + +permissions: + contents: write + deployments: read + pull-requests: write + +concurrency: + group: pr-preview-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + deploy-preview: + name: Deploy web preview + if: github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ github.event.action == 'closed' && github.event.pull_request.base.sha || github.event.pull_request.head.sha }} + + - name: Set up Node.js + if: github.event.action != 'closed' + uses: actions/setup-node@v4 + with: + node-version: 20.20.2 + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Install frontend dependencies + if: github.event.action != 'closed' + working-directory: frontend + run: npm ci + + - name: Export interactive web preview + if: github.event.action != 'closed' + working-directory: frontend + env: + EXPO_PUBLIC_USE_FAKE_WS: 'true' + EXPO_PUBLIC_WS_URL: '' + TIMEFLOW_PREVIEW_BASE_URL: /${{ github.event.repository.name }}/pr-preview/pr-${{ github.event.number }} + run: npx expo export --platform web --dev --output-dir dist + + - name: Deploy preview and comment on PR + uses: rossjrw/pr-preview-action@ffa7509e91a3ec8dfc2e5536c4d5c1acdf7a6de9 # v1.8.1 + with: + source-dir: frontend/dist + preview-branch: gh-pages + umbrella-dir: pr-preview + action: auto + wait-for-pages-deployment: true + comment: true + qr-code: true diff --git a/frontend/app.config.js b/frontend/app.config.js new file mode 100644 index 0000000..6336807 --- /dev/null +++ b/frontend/app.config.js @@ -0,0 +1,13 @@ +module.exports = ({ config }) => { + const previewBaseUrl = process.env.TIMEFLOW_PREVIEW_BASE_URL?.trim(); + + if (!previewBaseUrl) return config; + + return { + ...config, + experiments: { + ...config.experiments, + baseUrl: previewBaseUrl, + }, + }; +}; From 1bc92e0079c0cfe1b9acc0739864c738d7cf17b1 Mon Sep 17 00:00:00 2001 From: mac Date: Fri, 31 Jul 2026 19:51:27 +0800 Subject: [PATCH 2/3] fix(frontend): secure location integration --- frontend/.env.example | 1 + .../app/session/sessionEndpoint.test.ts | 14 ++++++++--- frontend/app.json | 12 +++++---- frontend/package-lock.json | 13 ++++++++++ frontend/package.json | 1 + frontend/plugins/withTimeflowVoiceRecorder.js | 4 +-- .../app/integrations/useLocationReporting.ts | 25 +++++++++++++------ frontend/src/app/session/sessionEndpoint.ts | 14 ++++++++++- .../location/LocationReporter.ts | 8 +++--- 9 files changed, 68 insertions(+), 24 deletions(-) diff --git a/frontend/.env.example b/frontend/.env.example index 58135df..ae754ed 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -6,6 +6,7 @@ EXPO_PUBLIC_BAIDU_MAP_AK=replace-with-your-baidu-map-ak # The client appends its persisted device_id query parameter automatically. # Local device example: ws://192.168.1.10:8000/ws # Production example: wss://api.example.com/ws +# Release builds require wss://. Plain ws:// is accepted only in development. EXPO_PUBLIC_WS_URL= # Use in-process FakeWsServer when EXPO_PUBLIC_WS_URL is empty. diff --git a/frontend/__tests__/app/session/sessionEndpoint.test.ts b/frontend/__tests__/app/session/sessionEndpoint.test.ts index 457c936..eeeab4c 100644 --- a/frontend/__tests__/app/session/sessionEndpoint.test.ts +++ b/frontend/__tests__/app/session/sessionEndpoint.test.ts @@ -4,9 +4,9 @@ import { buildSessionWebSocketUrl, resolveSessionUserId } from '@/app/session/se describe('session endpoint compatibility', () => { it('adds the persisted device id to the backend WebSocket URL', () => { - expect(buildSessionWebSocketUrl('ws://127.0.0.1:8000/ws', 'device 1')).toBe( - 'ws://127.0.0.1:8000/ws?device_id=device+1', - ); + expect( + buildSessionWebSocketUrl('ws://127.0.0.1:8000/ws', 'device 1', { allowInsecure: true }), + ).toBe('ws://127.0.0.1:8000/ws?device_id=device+1'); }); it('replaces a stale device id while preserving other query parameters', () => { @@ -21,6 +21,14 @@ describe('session endpoint compatibility', () => { ); }); + it('rejects plaintext WebSocket endpoints in release mode', () => { + expect(() => + buildSessionWebSocketUrl('ws://api.example.com/ws', 'device_1', { + allowInsecure: false, + }), + ).toThrow('发布构建的 EXPO_PUBLIC_WS_URL 必须使用 wss://'); + }); + it('uses the MVP backend user when session.ready omits user_id', () => { expect(resolveSessionUserId(undefined)).toBe('default_user'); expect(resolveSessionUserId(' user_1 ')).toBe('user_1'); diff --git a/frontend/app.json b/frontend/app.json index 2a0a481..a8e622e 100644 --- a/frontend/app.json +++ b/frontend/app.json @@ -9,9 +9,7 @@ "ios": { "infoPlist": { "NSLocationWhenInUseUsageDescription": "允许 Timeflow 获取当前位置,以便在地图选点和地点提醒时使用。", - "NSLocationAlwaysAndWhenInUseUsageDescription": "允许 Timeflow 在后台获取当前位置,以便触发地点提醒。", - "NSMicrophoneUsageDescription": "允许 Timeflow 录制语音,以便将语音整理成日程。", - "UIBackgroundModes": ["location"] + "NSMicrophoneUsageDescription": "允许 Timeflow 录制语音,以便将语音整理成日程。" }, "supportsTablet": true }, @@ -20,7 +18,6 @@ "permissions": [ "ACCESS_COARSE_LOCATION", "ACCESS_FINE_LOCATION", - "ACCESS_BACKGROUND_LOCATION", "RECORD_AUDIO", "SCHEDULE_EXACT_ALARM", "POST_NOTIFICATIONS", @@ -28,7 +25,6 @@ "SYSTEM_ALERT_WINDOW", "REQUEST_IGNORE_BATTERY_OPTIMIZATIONS", "FOREGROUND_SERVICE", - "FOREGROUND_SERVICE_LOCATION", "FOREGROUND_SERVICE_MEDIA_PLAYBACK", "VIBRATE" ], @@ -44,6 +40,12 @@ "plugins": [ "./plugins/withTimeflowAlarm", "./plugins/withTimeflowVoiceRecorder", + [ + "expo-location", + { + "locationWhenInUsePermission": "允许 Timeflow 在应用使用期间获取当前位置,以便触发地点提醒。" + } + ], [ "expo-image-picker", { diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ff093b9..559dee2 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -12,6 +12,7 @@ "@expo/metro-runtime": "~57.0.7", "expo": "~57.0.8", "expo-image-picker": "~57.0.6", + "expo-location": "~57.0.7", "expo-status-bar": "~57.0.1", "lucide-react-native": "^1.27.0", "react": "19.2.3", @@ -6443,6 +6444,18 @@ "expo": "*" } }, + "node_modules/expo-location": { + "version": "57.0.7", + "resolved": "https://registry.npmjs.org/expo-location/-/expo-location-57.0.7.tgz", + "integrity": "sha512-HPsS6Sse8GgMv9QiENXUEp9awl0O6iX0mFKJkxsHtC1HiouDXKCDlSnW66/E6U1ykwZ2M9ymJnZLRpWu0ua+/g==", + "license": "MIT", + "dependencies": { + "@expo/image-utils": "^0.11.4" + }, + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-modules-autolinking": { "version": "57.0.9", "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-57.0.9.tgz", diff --git a/frontend/package.json b/frontend/package.json index 7015c5b..8e4cee0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,6 +12,7 @@ "@expo/metro-runtime": "~57.0.7", "expo": "~57.0.8", "expo-image-picker": "~57.0.6", + "expo-location": "~57.0.7", "expo-status-bar": "~57.0.1", "lucide-react-native": "^1.27.0", "react": "19.2.3", diff --git a/frontend/plugins/withTimeflowVoiceRecorder.js b/frontend/plugins/withTimeflowVoiceRecorder.js index c1b6c9f..100b0ef 100644 --- a/frontend/plugins/withTimeflowVoiceRecorder.js +++ b/frontend/plugins/withTimeflowVoiceRecorder.js @@ -3,7 +3,7 @@ const { AndroidConfig, createRunOncePlugin, withAndroidManifest } = require('exp const PACKAGE_NAME = 'timeflow-voice-recorder'; const RECORD_AUDIO = 'android.permission.RECORD_AUDIO'; -/** Keeps microphone and LAN ws:// support in generated release manifests. */ +/** Keeps the microphone permission in generated native manifests. */ function withTimeflowVoiceRecorder(config) { config = AndroidConfig.Permissions.withPermissions(config, [RECORD_AUDIO]); config = withAndroidManifest(config, (config) => { @@ -16,8 +16,6 @@ function withTimeflowVoiceRecorder(config) { } } - const application = AndroidConfig.Manifest.getMainApplicationOrThrow(manifest); - application.$['android:usesCleartextTraffic'] = 'true'; return config; }); return config; diff --git a/frontend/src/app/integrations/useLocationReporting.ts b/frontend/src/app/integrations/useLocationReporting.ts index 7f71979..9170cbe 100644 --- a/frontend/src/app/integrations/useLocationReporting.ts +++ b/frontend/src/app/integrations/useLocationReporting.ts @@ -1,4 +1,5 @@ import { useEffect, useMemo } from 'react'; +import { AppState, type AppStateStatus } from 'react-native'; import type { ConnectionStatus } from '@/contracts'; import type { Schedule } from '@/features/schedule'; @@ -15,7 +16,7 @@ export function useLocationReporting(options: { client: LocationTransport | null; connectionStatus: ConnectionStatus; items: Schedule[]; - /** Tests and native hosts may provide a concrete background-aware provider. */ + /** Tests and native hosts may provide a concrete foreground provider. */ provider?: LocationProvider; }) { const { client, connectionStatus, items, provider } = options; @@ -25,11 +26,21 @@ export function useLocationReporting(options: { ); useEffect(() => { - if (!reporter || connectionStatus !== 'ready') { - reporter?.stop(); - return; - } - reporter.syncArmedSchedules(items); - return () => reporter.stop(); + if (!reporter) return; + + const syncForState = (state: AppStateStatus) => { + if (connectionStatus !== 'ready' || state !== 'active') { + reporter.stop(); + return; + } + reporter.syncArmedSchedules(items); + }; + + syncForState(AppState.currentState); + const subscription = AppState.addEventListener('change', syncForState); + return () => { + subscription.remove(); + reporter.stop(); + }; }, [connectionStatus, items, reporter]); } diff --git a/frontend/src/app/session/sessionEndpoint.ts b/frontend/src/app/session/sessionEndpoint.ts index 6b49fd8..23a7dee 100644 --- a/frontend/src/app/session/sessionEndpoint.ts +++ b/frontend/src/app/session/sessionEndpoint.ts @@ -1,10 +1,22 @@ const LEGACY_BACKEND_USER_ID = 'default_user'; -export function buildSessionWebSocketUrl(baseUrl: string, deviceId: string): string { +function isDevelopmentBuild(): boolean { + return typeof __DEV__ !== 'undefined' && __DEV__; +} + +export function buildSessionWebSocketUrl( + baseUrl: string, + deviceId: string, + options: { allowInsecure?: boolean } = {}, +): string { const url = new URL(baseUrl); if (url.protocol !== 'ws:' && url.protocol !== 'wss:') { throw new Error('EXPO_PUBLIC_WS_URL 必须使用 ws:// 或 wss://'); } + const allowInsecure = options.allowInsecure ?? isDevelopmentBuild(); + if (url.protocol === 'ws:' && !allowInsecure) { + throw new Error('发布构建的 EXPO_PUBLIC_WS_URL 必须使用 wss://'); + } url.searchParams.set('device_id', deviceId); return url.toString(); } diff --git a/frontend/src/infrastructure/location/LocationReporter.ts b/frontend/src/infrastructure/location/LocationReporter.ts index 61cd804..d2e5232 100644 --- a/frontend/src/infrastructure/location/LocationReporter.ts +++ b/frontend/src/infrastructure/location/LocationReporter.ts @@ -1,4 +1,4 @@ -import { requireOptionalNativeModule } from 'expo'; +import * as ExpoLocation from 'expo-location'; import { Platform } from 'react-native'; import type { LocationReport, LocationReportAck, Schedule, WsJsonMessage } from '@/contracts'; @@ -73,12 +73,10 @@ function isGranted(permission: ExpoLocationPermission | null | undefined): boole return permission?.granted === true || permission?.status === 'granted'; } -/** Native Expo location provider. The module must be linked by the host build. */ +/** Native foreground location provider backed by the linked Expo module. */ export class ExpoLocationProvider implements LocationProvider { constructor( - private readonly module: ExpoLocationModule | null = requireOptionalNativeModule( - 'ExpoLocation', - ), + private readonly module: ExpoLocationModule | null = ExpoLocation as unknown as ExpoLocationModule, ) {} async getCurrentSample(): Promise { From 479da4220f20694a5043bf0ba9f5f8b0968eeed1 Mon Sep 17 00:00:00 2001 From: mac Date: Fri, 31 Jul 2026 19:56:18 +0800 Subject: [PATCH 3/3] docs(ci): clarify fork preview policy --- .github/workflows/pr-preview.yml | 23 +++++++++++++++++------ README.md | 7 +++++++ 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/.github/workflows/pr-preview.yml b/.github/workflows/pr-preview.yml index f981060..bfd2897 100644 --- a/.github/workflows/pr-preview.yml +++ b/.github/workflows/pr-preview.yml @@ -1,22 +1,33 @@ -name: PR Preview +name: PR Preview (same-repository branches) on: pull_request: types: [opened, reopened, synchronize, closed] -permissions: - contents: write - deployments: read - pull-requests: write +permissions: {} concurrency: group: pr-preview-${{ github.event.pull_request.number }} cancel-in-progress: true jobs: + explain-fork-limit: + name: Explain fork preview limitation + if: github.event.pull_request.head.repo.full_name != github.repository + permissions: + contents: read + runs-on: ubuntu-latest + steps: + - name: Record why deployment is skipped + run: echo "Fork preview deployment is intentionally disabled because pull_request workflows cannot safely grant write credentials to untrusted fork code." + deploy-preview: - name: Deploy web preview + name: Deploy same-repository web preview if: github.event.pull_request.head.repo.full_name == github.repository + permissions: + contents: write + deployments: read + pull-requests: write runs-on: ubuntu-latest steps: - name: Checkout diff --git a/README.md b/README.md index ef59618..f7a9eb5 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,10 @@ # TimeFlow +## Pull request web previews +The preview workflow deploys branches from this repository and comments with +their Pages URL. Pull requests from forks are intentionally limited to a +read-only explanation job: running fork code with the write credentials needed +to update `gh-pages` and post comments would expose repository privileges to +untrusted changes. Maintainers can create a trusted same-repository branch when +a fork contribution needs an interactive preview.