diff --git a/fastlane/Fastfile b/fastlane/Fastfile index a9baea6b2..7776fbafe 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -2,8 +2,8 @@ fastlane_require "base64" fastlane_require "fileutils" fastlane_require "json" -IOS_APP_VERSION = "2.2.3" -ANDROID_APP_VERSION = "2.2.0" # run `fastlane bootstrap` after editing this +IOS_APP_VERSION = "2.2.4" +ANDROID_APP_VERSION = "2.2.1" # run `fastlane bootstrap` after editing this PROJECT_NAME = "Discourse" IOS_TEAM_ID = "6T3LU73T8S" KEYS_REPOSITORY = "git@github.com:discourse-org/discourse-mobile-keys.git" @@ -172,8 +172,8 @@ platform :ios do export_method: "app-store", export_options: { provisioningProfiles: { - "org.discourse.DiscourseApp" => "match AppStore org.discourse.DiscourseApp 1776778136", - "org.discourse.DiscourseApp.ShareExtension" => "match AppStore org.discourse.DiscourseApp.ShareExtension 1776778138" + "org.discourse.DiscourseApp" => "match AppStore org.discourse.DiscourseApp", + "org.discourse.DiscourseApp.ShareExtension" => "match AppStore org.discourse.DiscourseApp.ShareExtension" } }, clean: true, @@ -194,8 +194,8 @@ platform :ios do export_method: "ad-hoc", export_options: { provisioningProfiles: { - "org.discourse.DiscourseApp" => "match AdHoc org.discourse.DiscourseApp 1776778144", - "org.discourse.DiscourseApp.ShareExtension" => "match AdHoc org.discourse.DiscourseApp.ShareExtension 1776778146" + "org.discourse.DiscourseApp" => "match AdHoc org.discourse.DiscourseApp", + "org.discourse.DiscourseApp.ShareExtension" => "match AdHoc org.discourse.DiscourseApp.ShareExtension" } }, clean: true, diff --git a/ios/Discourse.xcodeproj/project.pbxproj b/ios/Discourse.xcodeproj/project.pbxproj index 7641b07be..3afa5210d 100644 --- a/ios/Discourse.xcodeproj/project.pbxproj +++ b/ios/Discourse.xcodeproj/project.pbxproj @@ -383,6 +383,7 @@ "${PODS_CONFIGURATION_BUILD_DIR}/React-cxxreact/React-cxxreact_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/boost/boost_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/glog/glog_privacy.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/react-native-blob-util/ReactNativeBlobUtilPrivacyInfo.bundle", "${PODS_ROOT}/../../node_modules/@react-native-vector-icons/fontawesome5/fonts/FontAwesome5_Brands.ttf", "${PODS_ROOT}/../../node_modules/@react-native-vector-icons/fontawesome5/fonts/FontAwesome5_Regular.ttf", "${PODS_ROOT}/../../node_modules/@react-native-vector-icons/fontawesome5/fonts/FontAwesome5_Solid.ttf", @@ -398,6 +399,7 @@ "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-cxxreact_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/boost_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/glog_privacy.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ReactNativeBlobUtilPrivacyInfo.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FontAwesome5_Brands.ttf", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FontAwesome5_Regular.ttf", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FontAwesome5_Solid.ttf", @@ -468,12 +470,12 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Discourse/Discourse.entitlements; - CODE_SIGN_IDENTITY = "iPhone Developer"; + CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 700; + CURRENT_PROJECT_VERSION = 709; DEAD_CODE_STRIPPING = YES; - DEVELOPMENT_TEAM = 6T3LU73T8S; + DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 6T3LU73T8S; ENABLE_BITCODE = NO; FRAMEWORK_SEARCH_PATHS = ( @@ -482,6 +484,7 @@ ); HEADER_SEARCH_PATHS = "$(inherited)"; INFOPLIST_FILE = Discourse/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = Discourse; IPHONEOS_DEPLOYMENT_TARGET = 15.6; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; LIBRARY_SEARCH_PATHS = ( @@ -497,8 +500,8 @@ ); PRODUCT_BUNDLE_IDENTIFIER = org.discourse.DiscourseApp; PRODUCT_NAME = Discourse; - PROVISIONING_PROFILE_SPECIFIER = "match AdHoc org.discourse.DiscourseApp"; - "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "match AdHoc org.discourse.DiscourseApp 1784743760"; + PROVISIONING_PROFILE_SPECIFIER = ""; + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "match AdHoc org.discourse.DiscourseApp"; SWIFT_OBJC_BRIDGING_HEADER = "Discourse-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; @@ -516,7 +519,7 @@ CODE_SIGN_ENTITLEMENTS = Discourse/Discourse.entitlements; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 700; + CURRENT_PROJECT_VERSION = 709; DEVELOPMENT_TEAM = 6T3LU73T8S; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 6T3LU73T8S; ENABLE_BITCODE = NO; @@ -526,6 +529,7 @@ ); HEADER_SEARCH_PATHS = "$(inherited)"; INFOPLIST_FILE = Discourse/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = Discourse; IPHONEOS_DEPLOYMENT_TARGET = 15.6; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; LIBRARY_SEARCH_PATHS = ( @@ -543,7 +547,7 @@ PRODUCT_NAME = Discourse; PROVISIONING_PROFILE = "8a5dde79-abbd-4707-a921-2b4412ef65ad"; PROVISIONING_PROFILE_SPECIFIER = "match AppStore org.discourse.DiscourseApp"; - "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "match AppStore org.discourse.DiscourseApp 1776778136"; + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "match AppStore org.discourse.DiscourseApp"; SWIFT_OBJC_BRIDGING_HEADER = "Discourse-Bridging-Header.h"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -681,10 +685,11 @@ CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; - CODE_SIGN_IDENTITY = "iPhone Distribution"; + CODE_SIGN_IDENTITY = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; DEBUG_INFORMATION_FORMAT = dwarf; - DEVELOPMENT_TEAM = 6T3LU73T8S; + DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 6T3LU73T8S; GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = ShareExtension/Info.plist; @@ -695,8 +700,8 @@ MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = org.discourse.DiscourseApp.ShareExtension; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = "match AdHoc org.discourse.DiscourseApp.ShareExtension"; - "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "match AdHoc org.discourse.DiscourseApp.ShareExtension 1784743763"; + PROVISIONING_PROFILE_SPECIFIER = ""; + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "match AdHoc org.discourse.DiscourseApp.ShareExtension"; SKIP_INSTALL = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; @@ -731,7 +736,7 @@ PRODUCT_BUNDLE_IDENTIFIER = org.discourse.DiscourseApp.ShareExtension; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = "match AdHoc org.discourse.DiscourseApp.ShareExtension"; - "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "match AdHoc org.discourse.DiscourseApp.ShareExtension 1776778146"; + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "match AppStore org.discourse.DiscourseApp.ShareExtension"; SKIP_INSTALL = YES; SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; SWIFT_VERSION = 5.0; diff --git a/ios/Discourse/Info.plist b/ios/Discourse/Info.plist index 7b7a260ce..bb95d9b52 100644 --- a/ios/Discourse/Info.plist +++ b/ios/Discourse/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 2.2.3 + 2.2.4 CFBundleSignature ???? CFBundleURLTypes @@ -37,7 +37,7 @@ CFBundleVersion - 700 + 709 Fabric APIKey diff --git a/ios/Discourse/PrivacyInfo.xcprivacy b/ios/Discourse/PrivacyInfo.xcprivacy index 3338c9ff4..6959894a4 100644 --- a/ios/Discourse/PrivacyInfo.xcprivacy +++ b/ios/Discourse/PrivacyInfo.xcprivacy @@ -18,6 +18,7 @@ NSPrivacyAccessedAPITypeReasons C617.1 + 0A2A.1 @@ -34,6 +35,7 @@ NSPrivacyAccessedAPITypeReasons 85F4.1 + E174.1 diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 400121e15..be5529982 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1648,6 +1648,35 @@ PODS: - React-RCTFBReactNativeSpec - ReactCommon/turbomodule/core - SocketRocket + - react-native-blob-util (0.24.10): + - boost + - DoubleConversion + - fast_float + - fmt + - glog + - hermes-engine + - RCT-Folly + - RCT-Folly/Fabric + - RCTRequired + - RCTTypeSafety + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - SocketRocket + - Yoga - react-native-blur (4.4.1): - boost - DoubleConversion @@ -2722,6 +2751,7 @@ DEPENDENCIES: - React-logger (from `../node_modules/react-native/ReactCommon/logger`) - React-Mapbuffer (from `../node_modules/react-native/ReactCommon`) - React-microtasksnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/microtasks`) + - react-native-blob-util (from `../node_modules/react-native-blob-util`) - "react-native-blur (from `../node_modules/@react-native-community/blur`)" - react-native-key-pair (from `../node_modules/react-native-key-pair`) - react-native-safari-view (from `../node_modules/react-native-safari-view`) @@ -2862,6 +2892,8 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native/ReactCommon" React-microtasksnativemodule: :path: "../node_modules/react-native/ReactCommon/react/nativemodule/microtasks" + react-native-blob-util: + :path: "../node_modules/react-native-blob-util" react-native-blur: :path: "../node_modules/@react-native-community/blur" react-native-key-pair: @@ -2968,12 +3000,12 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native/ReactCommon/yoga" SPEC CHECKSUMS: - boost: 1dca942403ed9342f98334bf4c3621f011aa7946 - DoubleConversion: f16ae600a246532c4020132d54af21d0ddb2a385 + boost: 7e761d76ca2ce687f7cc98e698152abd03a18f90 + DoubleConversion: cb417026b2400c8f53ae97020b2be961b59470cb fast_float: b32c788ed9c6a8c584d114d0047beda9664e7cc6 FBLazyVector: 86588b5a1547e7a417942a08f49559b184e002c8 - fmt: 01b82d4ca6470831d1cc0852a1af644be019e8f6 - glog: 08b301085f15bcbb6ff8632a8ebaf239aae04e6a + fmt: a40bb5bd0294ea969aaaba240a927bd33d878cdd + glog: 5683914934d5b6e4240e497e0f4a3b42d1854183 hermes-engine: bbc1152da7d2d40f9e59c28acc6576fcf5d28e2a RCT-Folly: 846fda9475e61ec7bcbf8a3fe81edfcaeb090669 RCTDeprecation: 300c5eb91114d4339b0bb39505d0f4824d7299b7 @@ -3008,6 +3040,7 @@ SPEC CHECKSUMS: React-logger: 7cfc7b1ae1f8e5fe5097f9c746137cc3a8fad4ce React-Mapbuffer: 7018c5b7da5b13ed22fe55dae51d50187a00b2d7 React-microtasksnativemodule: 8ff9cb220a8efa625b5885996bd69e69db9edf02 + react-native-blob-util: 5181e5a619545573b57bcb82efad01bf46e41402 react-native-blur: 477fac85a42b5506232ef1d2b82aad359feadd26 react-native-key-pair: f69098449fa3f63baac4f4280910d4acb5ec3d89 react-native-safari-view: 07dc856a2663fef31eaca6beb79b111b8f6cf1f2 diff --git a/ios/ShareExtension/Info.plist b/ios/ShareExtension/Info.plist index a01b4454c..2d134be74 100644 --- a/ios/ShareExtension/Info.plist +++ b/ios/ShareExtension/Info.plist @@ -17,9 +17,9 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 2.2.3 + 2.2.4 CFBundleVersion - 700 + 709 NSExtension NSExtensionAttributes diff --git a/js/lib/__tests__/handleDownload.test.js b/js/lib/__tests__/handleDownload.test.js new file mode 100644 index 000000000..e0f488d93 --- /dev/null +++ b/js/lib/__tests__/handleDownload.test.js @@ -0,0 +1,135 @@ +/* @flow */ +'use strict'; + +import { Alert, Platform, Share } from 'react-native'; +import ReactNativeBlobUtil from 'react-native-blob-util'; +import handleDownload, { + MAX_BRIDGED_DOWNLOAD_BYTES, + sanitizeFilename, + validateDownloadMessage, +} from '../handleDownload'; + +jest.mock('i18n-js', () => ({ + t: key => key, +})); + +jest.mock('react-native-blob-util', () => ({ + fs: { + dirs: { CacheDir: '/cache' }, + mkdir: jest.fn(() => Promise.resolve()), + writeFile: jest.fn(() => Promise.resolve()), + unlink: jest.fn(() => Promise.resolve()), + }, +})); + +function message(overrides = {}) { + return { + type: 'download', + filename: 'theme.zip', + mimeType: 'application/zip', + data: 'eA==', + ...overrides, + }; +} + +describe('handleDownload', () => { + beforeEach(() => { + jest.clearAllMocks(); + Platform.OS = 'ios'; + Share.share = jest.fn(() => Promise.resolve()); + Alert.alert = jest.fn(); + }); + + test('strips path separators from filenames', () => { + expect(sanitizeFilename('../bad/name:file.zip')).toBe( + '.._bad_name_file.zip', + ); + expect(sanitizeFilename('')).toBe('download'); + expect(sanitizeFilename(null)).toBe('download'); + }); + + test('rejects messages missing required fields', () => { + expect(() => validateDownloadMessage(null)).toThrow( + 'Invalid download request', + ); + expect(() => validateDownloadMessage(message({ type: 'other' }))).toThrow(); + expect(() => validateDownloadMessage(message({ filename: 123 }))).toThrow(); + }); + + test('rejects non-base64 payloads', () => { + expect(() => + validateDownloadMessage(message({ data: 'not base64!' })), + ).toThrow('Invalid download data'); + expect(() => validateDownloadMessage(message({ data: 'abc' }))).toThrow( + 'Invalid download data', + ); + }); + + test('rejects payloads over the receiver-side size cap', () => { + const overCap = 'A'.repeat( + Math.ceil(((MAX_BRIDGED_DOWNLOAD_BYTES + 4) * 4) / 3 / 4) * 4, + ); + expect(() => validateDownloadMessage(message({ data: overCap }))).toThrow( + 'Download is too large', + ); + }); + + test('writes to a unique directory so the shared filename is preserved', async () => { + await handleDownload(message()); + + expect(ReactNativeBlobUtil.fs.mkdir).toHaveBeenCalledWith( + expect.stringMatching(/^\/cache\/download-/), + ); + expect(ReactNativeBlobUtil.fs.writeFile).toHaveBeenCalledWith( + expect.stringMatching(/^\/cache\/download-.+\/theme\.zip$/), + 'eA==', + 'base64', + ); + expect(Share.share).toHaveBeenCalledWith( + expect.objectContaining({ + url: expect.stringMatching( + /^file:\/\/\/cache\/download-.+\/theme\.zip$/, + ), + }), + ); + expect(Alert.alert).not.toHaveBeenCalled(); + }); + + test('unlinks the temp directory after the cleanup delay', async () => { + jest.useFakeTimers(); + try { + await handleDownload(message()); + + expect(ReactNativeBlobUtil.fs.unlink).not.toHaveBeenCalled(); + jest.runAllTimers(); + expect(ReactNativeBlobUtil.fs.unlink).toHaveBeenCalledWith( + expect.stringMatching(/^\/cache\/download-/), + ); + } finally { + jest.useRealTimers(); + } + }); + + test('alerts a generic failure when the write fails', async () => { + ReactNativeBlobUtil.fs.writeFile.mockRejectedValueOnce( + new Error('disk full'), + ); + + await handleDownload(message()); + + expect(Alert.alert).toHaveBeenCalledWith( + 'download.failed_title', + 'download.failed_message', + ); + }); + + test('ignores messages on non-iOS platforms', async () => { + Platform.OS = 'android'; + + await handleDownload(message()); + + expect(ReactNativeBlobUtil.fs.mkdir).not.toHaveBeenCalled(); + expect(ReactNativeBlobUtil.fs.writeFile).not.toHaveBeenCalled(); + expect(Share.share).not.toHaveBeenCalled(); + }); +}); diff --git a/js/lib/handleDownload.js b/js/lib/handleDownload.js new file mode 100644 index 000000000..e1367d71f --- /dev/null +++ b/js/lib/handleDownload.js @@ -0,0 +1,111 @@ +/* @flow */ +'use strict'; + +import { Alert, Platform, Share } from 'react-native'; +import ReactNativeBlobUtil from 'react-native-blob-util'; +import i18n from 'i18n-js'; + +export const MAX_BRIDGED_DOWNLOAD_BYTES = 25 * 1024 * 1024; + +const CLEANUP_DELAY = 20_000; + +let downloadSequence = 0; + +export function sanitizeFilename(name) { + if (typeof name !== 'string') { + return 'download'; + } + + const cleaned = name.replace(/[/\\:\0]+/g, '_').trim(); + return cleaned || 'download'; +} + +function base64ByteLength(value) { + const padding = value.endsWith('==') ? 2 : value.endsWith('=') ? 1 : 0; + return Math.floor((value.length * 3) / 4) - padding; +} + +export function validateDownloadMessage(message) { + if ( + !message || + typeof message !== 'object' || + message.type !== 'download' || + typeof message.filename !== 'string' || + typeof message.data !== 'string' + ) { + throw new Error('Invalid download request'); + } + + if ( + !/^[a-z0-9+/]*={0,2}$/i.test(message.data) || + message.data.length % 4 !== 0 + ) { + throw new Error('Invalid download data'); + } + if (base64ByteLength(message.data) > MAX_BRIDGED_DOWNLOAD_BYTES) { + throw new Error('Download is too large'); + } + + return { + filename: sanitizeFilename(message.filename), + mimeType: + typeof message.mimeType === 'string' + ? message.mimeType + : 'application/octet-stream', + data: message.data, + }; +} + +function temporaryDirectory() { + downloadSequence += 1; + const unique = `${Date.now()}-${downloadSequence}-${Math.random() + .toString(36) + .slice(2, 10)}`; + return `${ReactNativeBlobUtil.fs.dirs.CacheDir}/download-${unique}`; +} + +function scheduleCleanup(dir) { + setTimeout(() => { + ReactNativeBlobUtil.fs.unlink(dir).catch(() => {}); + }, CLEANUP_DELAY); +} + +// The web side only selects the "bridge" download strategy on iOS (see +// discourse/discourse's attachmentDownloadStrategy). Android's WebView has +// handled downloads via the system DownloadManager since react-native-webview +// 2.15, so it should never send us a `type: "download"` message — this guard +// is defense-in-depth in case a stale web build or app version mismatch +// still fires one. +export default async function handleDownload(message) { + if (Platform.OS !== 'ios') { + console.warn( + 'handleDownload: ignoring download message on non-iOS platform', + ); + return; + } + + let dir; + + try { + const download = validateDownloadMessage(message); + dir = temporaryDirectory(); + await ReactNativeBlobUtil.fs.mkdir(dir); + const path = `${dir}/${download.filename}`; + await ReactNativeBlobUtil.fs.writeFile(path, download.data, 'base64'); + + await Share.share({ + url: `file://${path}`, + title: download.filename, + }); + } catch (error) { + console.warn('handleDownload failed', error); + Alert.alert( + i18n.t('download.failed_title'), + i18n.t('download.failed_message'), + ); + } finally { + if (dir) { + scheduleCleanup(dir); + } + } +} diff --git a/js/locale/en.json b/js/locale/en.json index e7e923909..84734650c 100644 --- a/js/locale/en.json +++ b/js/locale/en.json @@ -1,4 +1,8 @@ { + "download": { + "failed_title": "Download failed", + "failed_message": "Something went wrong while preparing this download. Please try again." + }, "add_site": "Add Site", "add_sites": "Get started and follow the Discourse communities you love!", "no_sites_yet": "Welcome to Discourse Hub!", diff --git a/js/screens/WebViewScreenComponents/WebViewComponent.js b/js/screens/WebViewScreenComponents/WebViewComponent.js index e957fe57a..4cfac043a 100644 --- a/js/screens/WebViewScreenComponents/WebViewComponent.js +++ b/js/screens/WebViewScreenComponents/WebViewComponent.js @@ -17,6 +17,7 @@ import { import { WebView } from 'react-native-webview'; import ErrorScreen from '../WebViewScreenComponents/ErrorScreen'; import ProgressBar from '../../ProgressBar'; +import handleDownload from '../../lib/handleDownload'; import chroma from 'chroma-js'; import SafariView from 'react-native-safari-view'; import i18n from 'i18n-js'; @@ -198,6 +199,11 @@ class WebViewComponent extends React.Component { allowsLinkPreview={true} hideKeyboardAccessoryView={!Platform.isPad} webviewDebuggingEnabled={true} + injectedJavaScriptBeforeContentLoaded={ + Platform.OS === 'ios' + ? 'window.__discourseHubDownloadBridge = true; true;' + : undefined + } onLoadEnd={() => { this.webview.requestFocus(); }} @@ -452,7 +458,19 @@ class WebViewComponent extends React.Component { } _onMessage(event) { - let data = JSON.parse(event.nativeEvent.data); + let data; + + try { + data = JSON.parse(event.nativeEvent.data); + } catch (error) { + console.warn('WebView message: unparseable JSON', error); + return; + } + + if (data?.type === 'download') { + void handleDownload(data); + return; + } let { headerBg, shareUrl, dismiss, markRead, showLogin } = data; diff --git a/package.json b/package.json index 24fcefa8b..a65da6d74 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "react": "19.1.0", "react-native": "^0.80.2", "react-native-background-fetch": "^4.2.3", + "react-native-blob-util": "^0.24.10", "react-native-custom-tabs": "https://github.com/adminphoeniixx/react-native-custom-tabs", "react-native-device-info": "^13.1.0", "react-native-draggable-flatlist": "^4.0.3", diff --git a/yarn.lock b/yarn.lock index 75ccc1a10..14f249a1d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4954,6 +4954,20 @@ __metadata: languageName: node linkType: hard +"balanced-match@npm:^4.0.2": + version: 4.0.4 + resolution: "balanced-match@npm:4.0.4" + checksum: 10/fb07bb66a0959c2843fc055838047e2a95ccebb837c519614afb067ebfdf2fa967ca8d712c35ced07f2cd26fc6f07964230b094891315ad74f11eba3d53178a0 + languageName: node + linkType: hard + +"base-64@npm:1.0.0": + version: 1.0.0 + resolution: "base-64@npm:1.0.0" + checksum: 10/d10b64a1fc9b2c5a5f39f1ce1e6c9d1c5b249222bbfa3a0604c592d90623caf74419983feadd8a170f27dc0c3389704f72faafa3e645aeb56bfc030c93ff074a + languageName: node + linkType: hard + "base64-js@npm:^1.3.1, base64-js@npm:^1.5.1": version: 1.5.1 resolution: "base64-js@npm:1.5.1" @@ -5059,6 +5073,15 @@ __metadata: languageName: node linkType: hard +"brace-expansion@npm:^5.0.8": + version: 5.0.9 + resolution: "brace-expansion@npm:5.0.9" + dependencies: + balanced-match: "npm:^4.0.2" + checksum: 10/d8683d612919212c7cf298861acd1c15101e7e2ad186e7ae9747390e5b3fc9e9b55ce71107570d32985784d9d88fbbe438d725863aed7b0f3d08d5f080535eec + languageName: node + linkType: hard + "braces@npm:^3.0.3": version: 3.0.3 resolution: "braces@npm:3.0.3" @@ -6237,6 +6260,7 @@ __metadata: react: "npm:19.1.0" react-native: "npm:^0.80.2" react-native-background-fetch: "npm:^4.2.3" + react-native-blob-util: "npm:^0.24.10" react-native-custom-tabs: "https://github.com/adminphoeniixx/react-native-custom-tabs" react-native-device-info: "npm:^13.1.0" react-native-draggable-flatlist: "npm:^4.0.3" @@ -7643,6 +7667,17 @@ __metadata: languageName: node linkType: hard +"glob@npm:13.0.6": + version: 13.0.6 + resolution: "glob@npm:13.0.6" + dependencies: + minimatch: "npm:^10.2.2" + minipass: "npm:^7.1.3" + path-scurry: "npm:^2.0.2" + checksum: 10/201ad69e5f0aa74e1d8c00a481581f8b8c804b6a4fbfabeeb8541f5d756932800331daeba99b58fb9e4cd67e12ba5a7eba5b82fb476691588418060b84353214 + languageName: node + linkType: hard + "glob@npm:^10.2.2, glob@npm:^10.4.5": version: 10.4.5 resolution: "glob@npm:10.4.5" @@ -9886,6 +9921,13 @@ __metadata: languageName: node linkType: hard +"lru-cache@npm:^11.0.0": + version: 11.5.2 + resolution: "lru-cache@npm:11.5.2" + checksum: 10/122789c66605ddf29df58ff279e9e2c9499499d0b80b895ec524496f14bcde045d1582e3e38008f3457226d98067556719573b352119d6be8bdb1f8849f85681 + languageName: node + linkType: hard + "lru-cache@npm:^5.1.1": version: 5.1.1 resolution: "lru-cache@npm:5.1.1" @@ -10331,6 +10373,15 @@ __metadata: languageName: node linkType: hard +"minimatch@npm:^10.2.2": + version: 10.2.6 + resolution: "minimatch@npm:10.2.6" + dependencies: + brace-expansion: "npm:^5.0.8" + checksum: 10/a5e43f7c57d6061a77da320b42aa35ffff826030cf67c83c0eee47a26ede4d66486935881492b968c609bf27a7d48cc4b9f54b2806643cd8fb792fcf9240cc1b + languageName: node + linkType: hard + "minimatch@npm:^5.0.1": version: 5.1.6 resolution: "minimatch@npm:5.1.6" @@ -10430,6 +10481,13 @@ __metadata: languageName: node linkType: hard +"minipass@npm:^7.1.3": + version: 7.1.3 + resolution: "minipass@npm:7.1.3" + checksum: 10/175e4d5e20980c3cd316ae82d2c031c42f6c746467d8b1905b51060a0ba4461441a0c25bb67c025fd9617f9a3873e152c7b543c6b5ac83a1846be8ade80dffd6 + languageName: node + linkType: hard + "minizlib@npm:^2.1.1": version: 2.1.2 resolution: "minizlib@npm:2.1.2" @@ -11495,6 +11553,16 @@ __metadata: languageName: node linkType: hard +"path-scurry@npm:^2.0.2": + version: 2.0.2 + resolution: "path-scurry@npm:2.0.2" + dependencies: + lru-cache: "npm:^11.0.0" + minipass: "npm:^7.1.2" + checksum: 10/2b4257422bcb870a4c2d205b3acdbb213a72f5e2250f61c80f79c9d014d010f82bdf8584441612c8e1fa4eb098678f5704a66fa8377d72646bad4be38e57a2c3 + languageName: node + linkType: hard + "path-type@npm:^4.0.0": version: 4.0.0 resolution: "path-type@npm:4.0.0" @@ -11922,6 +11990,19 @@ __metadata: languageName: node linkType: hard +"react-native-blob-util@npm:^0.24.10": + version: 0.24.10 + resolution: "react-native-blob-util@npm:0.24.10" + dependencies: + base-64: "npm:1.0.0" + glob: "npm:13.0.6" + peerDependencies: + react: "*" + react-native: "*" + checksum: 10/3999fbe4c856fd45f09248a80c324c5cd464d9774c705138e864e06d8dab826d2be1ccba25beea09ce1352b66922094eef35435612d5b5e9bde5443803f0269a + languageName: node + linkType: hard + "react-native-custom-tabs@https://github.com/adminphoeniixx/react-native-custom-tabs": version: 0.1.8 resolution: "react-native-custom-tabs@https://github.com/adminphoeniixx/react-native-custom-tabs.git#commit=7605cda9d9d691c36367e98d8513937454627ecd"