diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index a49b178..5048ee6 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -2,37 +2,20 @@ name: Build validation
on:
push:
+ branches: [main]
pull_request:
jobs:
build:
runs-on: ubuntu-latest
- strategy:
- matrix:
- node-version: [20.x, 22.x]
-
steps:
- uses: actions/checkout@v4
- - name: Use Node.js ${{ matrix.node-version }}
+ - name: Use Node.js 20
uses: actions/setup-node@v4
with:
- node-version: ${{ matrix.node-version }}
+ node-version: 20.x
cache: 'npm'
- name: Install dependencies
run: npm i
- run: npm run build
-
- lint:
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v4
- - name: Use Node.js 22
- uses: actions/setup-node@v4
- with:
- node-version: 22
- cache: 'npm'
- - name: Install dependencies
- run: npm i
- # Lint currently reporting lots of preexisting issues
- # - run: npm run lint
diff --git a/android/app/build.gradle b/android/app/build.gradle
index 729845e..478dbdd 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -13,8 +13,8 @@ android {
applicationId "com.donetick.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
- versionCode 26
- versionName "1.2.4"
+ versionCode 27
+ versionName "1.2.5"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
diff --git a/ios/App/App/Info.plist b/ios/App/App/Info.plist
index 5a58712..276929d 100644
--- a/ios/App/App/Info.plist
+++ b/ios/App/App/Info.plist
@@ -61,6 +61,11 @@
UIInterfaceOrientationLandscapeLeft
UIInterfaceOrientationLandscapeRight
+ NSAppTransportSecurity
+
+ NSAllowsLocalNetworking
+
+
UIViewControllerBasedStatusBarAppearance
CFBundleURLTypes
diff --git a/package.json b/package.json
index b73685f..2ae3abc 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "donetick",
"private": true,
- "version": "1.2.4",
+ "version": "1.2.6",
"type": "module",
"engines": {
"node": ">=20.0.0",
diff --git a/src/CapacitorListener.js b/src/CapacitorListener.js
index c8fdf21..8150569 100644
--- a/src/CapacitorListener.js
+++ b/src/CapacitorListener.js
@@ -48,6 +48,16 @@ const handleOAuthDeepLink = async url => {
const state = urlObj.searchParams.get('state')
if (code && state) {
+ // getLaunchUrl() persists across every WebView reload caused by
+ // window.location.href. If we're already on the OAuth handler page with
+ // the same code, skip re-navigating to avoid an infinite reload loop.
+ const currentCode = new URLSearchParams(window.location.search).get(
+ 'code',
+ )
+ if (window.location.pathname === '/auth/oauth2' && currentCode === code) {
+ return
+ }
+
// Store the OAuth params for the app to pick up
await Preferences.set({
key: 'oauth_callback',
@@ -258,7 +268,7 @@ const registerCapacitorListeners = () => {
console.log('[NFC] appUrlOpen:', event.url)
handleUrlOpen(event.url)
})
-
+
mobileApp.addListener('appStateChange', ({ isActive }) => {
focusManager.setFocused(isActive)
})
@@ -277,5 +287,6 @@ const registerCapacitorListeners = () => {
export {
registerCapacitorListeners,
- pushNotificationListenerRegistration as registerPushNotifications,
+ pushNotificationListenerRegistration as registerPushNotifications
}
+
diff --git a/src/hooks/useSSE.js b/src/hooks/useSSE.js
index 8a63f20..1653b63 100644
--- a/src/hooks/useSSE.js
+++ b/src/hooks/useSSE.js
@@ -1,3 +1,4 @@
+import { Capacitor } from '@capacitor/core'
import { useQueryClient } from '@tanstack/react-query'
import { EventSourcePolyfill } from 'event-source-polyfill'
import { useCallback, useEffect, useRef, useState } from 'react'
@@ -60,6 +61,24 @@ export const useSSE = () => {
return { url: sseUrl, token }
}, [token, isAuthenticated]) // Fixed: Added missing dependencies
+ // Exchange the JWT (sent via Authorization header by apiClient) for a
+ // short-lived, single-use SSE ticket. Used by the native EventSource path,
+ // which cannot send custom headers.
+ const fetchSSETicket = useCallback(async () => {
+ try {
+ const response = await apiClient.get('/realtime/sse/ticket')
+ if (!response || !response.ok) {
+ console.error('SSE: Ticket request failed', response?.status)
+ return null
+ }
+ const data = await response.json()
+ return data.ticket || null
+ } catch (err) {
+ console.error('SSE: Ticket request error', err)
+ return null
+ }
+ }, [])
+
const handleSSEMessage = useCallback(
event => {
try {
@@ -289,7 +308,7 @@ export const useSSE = () => {
}, [])
// Create connect function that can be called from anywhere
- const connect = useCallback(() => {
+ const connect = useCallback(async () => {
// Clear the scheduled flag when actually connecting
isReconnectScheduledRef.current = false
@@ -348,16 +367,49 @@ export const useSSE = () => {
setConnectionState(SSE_STATES.CONNECTING)
isManuallyClosedRef.current = false
- eventSourceRef.current = new EventSourcePolyfill(sseConfig.url, {
- headers: {
- Authorization: `Bearer ${localStorage.getItem('token')}`,
- 'Cache-Control': 'no-cache',
- Accept: 'text/event-stream',
- },
- withCredentials: true,
- heartbeatTimeout: 120000,
- silentTimeoutRetry: true,
- })
+ if (Capacitor.isNativePlatform()) {
+ // Capacitor's native HTTP bridge does not support streaming responses,
+ // which breaks EventSourcePolyfill (fetch/XHR based). Use the native
+ // EventSource instead, which uses the WKWebView HTTP stack directly.
+ // Native EventSource cannot send custom headers, so we first exchange
+ // our JWT (sent in the Authorization header) for a short-lived,
+ // single-use ticket and pass that ticket as a query parameter. This
+ // keeps the long-lived token out of URLs and proxy access logs.
+ const ticket = await fetchSSETicket()
+ if (!ticket) {
+ console.error('SSE: Failed to obtain connection ticket')
+ setError('Connection error occurred')
+ setConnectionState(SSE_STATES.CLOSED)
+ scheduleReconnect(
+ RECONNECT_INTERVALS[
+ Math.min(
+ reconnectAttemptsRef.current,
+ RECONNECT_INTERVALS.length - 1,
+ )
+ ],
+ 'ticket-fetch-failed',
+ )
+ return
+ }
+
+ const nativeUrl = new URL(sseConfig.url)
+ nativeUrl.searchParams.set('ticket', ticket)
+
+ eventSourceRef.current = new EventSource(nativeUrl.toString(), {
+ withCredentials: true,
+ })
+ } else {
+ eventSourceRef.current = new EventSourcePolyfill(sseConfig.url, {
+ headers: {
+ Authorization: `Bearer ${localStorage.getItem('token')}`,
+ 'Cache-Control': 'no-cache',
+ Accept: 'text/event-stream',
+ },
+ withCredentials: true,
+ heartbeatTimeout: 120000,
+ silentTimeoutRetry: true,
+ })
+ }
eventSourceRef.current.onopen = () => {
console.log('SSE connection opened')
@@ -573,8 +625,10 @@ export const useSSE = () => {
}
}, [
getSSEUrl,
+ fetchSSETicket,
handleSSEMessage,
stopHeartbeatMonitor,
+ scheduleReconnect,
isCircuitBreakerOpen,
showError,
])
diff --git a/src/views/Authorization/LoginView.jsx b/src/views/Authorization/LoginView.jsx
index 108ff14..48a3a92 100644
--- a/src/views/Authorization/LoginView.jsx
+++ b/src/views/Authorization/LoginView.jsx
@@ -310,7 +310,6 @@ const LoginView = () => {
const state = generateRandomState()
if (Capacitor.isNativePlatform()) {
- // For mobile devices, use a custom URL scheme for the redirect
const redirectUri = 'donetick://auth/oauth2'
const params = new URLSearchParams({