diff --git a/AppScope/app.json5 b/AppScope/app.json5 new file mode 100644 index 0000000000000000000000000000000000000000..c06b4d4d23d459b105deb56f0cd34aa8d500790e --- /dev/null +++ b/AppScope/app.json5 @@ -0,0 +1,10 @@ +{ + "app": { + "bundleName": "com.example.webinterception", + "vendor": "example", + "versionCode": 1000000, + "versionName": "1.0.0", + "icon": "$media:layered_image", + "label": "$string:app_name" + } +} diff --git a/AppScope/resources/base/element/string.json b/AppScope/resources/base/element/string.json new file mode 100644 index 0000000000000000000000000000000000000000..dd5d0eaf3676f63fecdcd94936f9d98a54e889dd --- /dev/null +++ b/AppScope/resources/base/element/string.json @@ -0,0 +1,16 @@ +{ + "string": [ + { + "name": "app_name", + "value": "WebInterception" + }, + { + "name": "internet_permission", + "value": "Obtain permission to access the network." + }, + { + "name": "network_info_permission", + "value": "Obtain permission to get the network information." + } + ] +} diff --git a/AppScope/resources/base/media/background.png b/AppScope/resources/base/media/background.png new file mode 100644 index 0000000000000000000000000000000000000000..923f2b3f27e915d6871871deea0420eb45ce102f Binary files /dev/null and b/AppScope/resources/base/media/background.png differ diff --git a/AppScope/resources/base/media/foreground.png b/AppScope/resources/base/media/foreground.png new file mode 100644 index 0000000000000000000000000000000000000000..eb9427585b36d14b12477435b6419d1f07b3e0bb Binary files /dev/null and b/AppScope/resources/base/media/foreground.png differ diff --git a/AppScope/resources/base/media/layered_image.json b/AppScope/resources/base/media/layered_image.json new file mode 100644 index 0000000000000000000000000000000000000000..fb49920440fb4d246c82f9ada275e26123a2136a --- /dev/null +++ b/AppScope/resources/base/media/layered_image.json @@ -0,0 +1,7 @@ +{ + "layered-image": + { + "background" : "$media:background", + "foreground" : "$media:foreground" + } +} \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000000000000000000000000000000000000..18795a48d6b12fcdc1aa7bac9a9cb99f83815267 --- /dev/null +++ b/LICENSE @@ -0,0 +1,78 @@ + Copyright (c) 2025 Huawei Device Co., Ltd. All rights reserved. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +Apache License, Version 2.0 +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. + +"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. + +"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). + +"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. + +"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. + +2. Grant of Copyright License. + +Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. + +Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. + +4. Redistribution. + +You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: +1.You must give any other recipients of the Work or Derivative Works a copy of this License; and +2.You must cause any modified files to carry prominent notices stating that You changed the files; and +3.You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and +4.If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. + +You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. + +5. Submission of Contributions. + +Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. + +6. Trademarks. + +This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. + +Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. + +In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. + +While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS \ No newline at end of file diff --git a/LocalServer/headerServer.js b/LocalServer/headerServer.js new file mode 100644 index 0000000000000000000000000000000000000000..02c5deb79d361b15862e71756708b6d1894d4fc0 --- /dev/null +++ b/LocalServer/headerServer.js @@ -0,0 +1,178 @@ +/* + * Copyright (c) 2025 Huawei Device Co., Ltd. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const express = require('express'); +const cors = require('cors'); +const cookieParser = require('cookie-parser'); +const fs = require('fs'); +const path = require('path'); + +// Creating the Express Server +const app = express(); + +// Parse JSON request bodies +app.use(express.json()); + +// Parse URL-encoded request bodies +app.use(express.urlencoded({ extended: true })); + +// Parse cookies +app.use(cookieParser()); + +// Configure CORS to allow cross-origin requests +app.use(cors({ + origin: '*', // allow all origins + methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS', 'PATCH'], + allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'], + credentials: true, + exposedHeaders: ['Content-Type', 'Authorization'] +})); + +// Load HTML template file once at startup +const templatePath = path.join(__dirname, 'templates', 'headerResponse.html'); +let htmlTemplate = ''; + +try { + htmlTemplate = fs.readFileSync(templatePath, 'utf-8'); + console.log('HTML template loaded successfully'); +} catch (error) { + console.error('Failed to load HTML template:', error.message); + // Fallback in case the template file is missing + htmlTemplate = '

Template load failed

'; +} + +// HTML escape helper +function escapeHtml(text) { + const map = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + }; + return String(text).replace(/[&<>"']/g, m => map[m]); +} + +// Format JSON objects for display +function formatJSON(obj) { + return JSON.stringify(obj, null, 2); +} + +// Get badge color by HTTP method +function getMethodColor(method) { + const colors = { + 'GET': '#61affe', + 'POST': '#49cc90', + 'PUT': '#fca130', + 'DELETE': '#f93e3e', + 'PATCH': '#50e3c2', + 'OPTIONS': '#9012fe' + }; + return colors[method] || '#999'; +} + +// Generate HTML response content +function generateHTMLResponse(req) { + const method = req.method; + const headers = req.headers; + const body = req.body || {}; + + // Build header table rows + function generateHeaderRows() { + const entries = Object.entries(headers); + if (entries.length === 0) { + return 'No headers'; + } + + return entries.map(([key, value]) => ` + + ${escapeHtml(key)} + ${escapeHtml(value)} + + `).join(''); + } + + // Build request body section when needed + function generateBodySection() { + if (Object.keys(body).length === 0) { + return ''; + } + return ` +
+

Request Body

+
${escapeHtml(formatJSON(body))}
+
+ `; + } + + return htmlTemplate + .replace(/\{\{METHOD\}\}/g, escapeHtml(method)) + .replace(/\{\{METHOD_COLOR\}\}/g, getMethodColor(method)) + .replace(/\{\{HEADER_ROWS\}\}/g, generateHeaderRows()) + .replace(/\{\{BODY_SECTION\}\}/g, generateBodySection()); +} + +// Handle all HTTP methods and echo headers +app.all('/api/headers', (req, res) => { + console.log('Received request:', req.method, req.url); + console.log('Headers:', req.headers); + + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.send(generateHTMLResponse(req)); +}); + +// Also handle the root path for convenience +app.all('/', (req, res) => { + console.log('Received root path request:', req.method, req.url); + + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.send(generateHTMLResponse(req)); +}); + +// Get local network IP address +function getLocalNetworkIP() { + const os = require('os'); + const interfaces = os.networkInterfaces(); + for (const name of Object.keys(interfaces)) { + const iface = interfaces[name]; + if (!iface) { + continue; + } + for (const addr of iface) { + // Skip internal (loopback) and non-IPv4 addresses + if (addr.family === 'IPv4' && !addr.internal) { + return addr.address; + } + } + } + return '127.0.0.1'; +} + +// Start server on port 8081 +const PORT = 8081; +const localIP = getLocalNetworkIP(); +app.listen(PORT, '0.0.0.0', () => { + console.log(`Header server listening on port ${PORT}`); + console.log('\n=== Access URLs ==='); + console.log(`Local: http://localhost:${PORT}/api/headers`); + console.log(`Local: http://127.0.0.1:${PORT}/api/headers`); + console.log(`Network: http://${localIP}:${PORT}/api/headers`); + console.log('\n=== Configuration for HarmonyOS App ==='); + console.log(`Use this IP in your app: ${localIP}`); + console.log(`Full URL: http://${localIP}:${PORT}/api/headers`); + console.log('\nSupports methods: GET, POST, PUT, DELETE, PATCH, OPTIONS'); + console.log('Responses are rendered as a styled HTML page'); + console.log('\nNote: Ensure your device is on the same local network as this server.'); +}); \ No newline at end of file diff --git a/LocalServer/package.json b/LocalServer/package.json new file mode 100644 index 0000000000000000000000000000000000000000..62f4753e65e2f43e243c3b44f42d0c239d48d805 --- /dev/null +++ b/LocalServer/package.json @@ -0,0 +1,21 @@ +{ + "name": "serve", + "version": "1.0.0", + "main": "app.js", + "scripts": { + "start": "node headerServer.js", + "forever:start": "forever start headerServer.js", + "forever:stop": "forever stop headerServer.js", + "forever:restart": "forever restart headerServer.js" + }, + "keywords": [], + "author": "", + "license": "ISC", + "description": "", + "dependencies": { + "cookie-parser": "^1.4.7", + "cors": "^2.8.5", + "express": "^5.1.0", + "forever": "^4.0.3" + } +} \ No newline at end of file diff --git a/LocalServer/templates/headerResponse.html b/LocalServer/templates/headerResponse.html new file mode 100644 index 0000000000000000000000000000000000000000..3cd4f59e13bf9de404c6274ea238252b6e4803af --- /dev/null +++ b/LocalServer/templates/headerResponse.html @@ -0,0 +1,189 @@ + + + + + + Request Headers - Header Server + + + +
+
+

+ Request Headers + {{METHOD}} +

+

Inspect the request headers intercepted by the proxy

+
+ +
+

Headers

+ + + + + + + + + {{HEADER_ROWS}} + +
HeaderValue
+
+ + {{BODY_SECTION}} +
+ + \ No newline at end of file diff --git a/README.en.md b/README.en.md deleted file mode 100644 index 9f1043b7a9da5ead678ebb091261dc30eb5f7208..0000000000000000000000000000000000000000 --- a/README.en.md +++ /dev/null @@ -1,36 +0,0 @@ -# WebInterceptor - -#### Description -实现网络拦截器Sample - -#### Software Architecture -Software architecture description - -#### Installation - -1. xxxx -2. xxxx -3. xxxx - -#### Instructions - -1. xxxx -2. xxxx -3. xxxx - -#### Contribution - -1. Fork the repository -2. Create Feat_xxx branch -3. Commit your code -4. Create Pull Request - - -#### Gitee Feature - -1. You can use Readme\_XXX.md to support different languages, such as Readme\_en.md, Readme\_zh.md -2. Gitee blog [blog.gitee.com](https://blog.gitee.com) -3. Explore open source project [https://gitee.com/explore](https://gitee.com/explore) -4. The most valuable open source project [GVP](https://gitee.com/gvp) -5. The manual of Gitee [https://gitee.com/help](https://gitee.com/help) -6. The most popular members [https://gitee.com/gitee-stars/](https://gitee.com/gitee-stars/) diff --git a/README.md b/README.md index e86006c3cc58190d48db16c01c61de07334fe3f7..48d08e30c387ded5b745e920a5516603328fb957 100644 --- a/README.md +++ b/README.md @@ -1,37 +1,142 @@ -# WebInterceptor +# 实现基于Web组件的请求拦截功能 -#### 介绍 -实现网络拦截器Sample +## 项目简介 -#### 软件架构 -软件架构说明 +本实例基于onLoadIntercept()接口、onInterceptRequest()接口和WebSchemeHandler请求拦截器实现了Web组件的请求拦截功能, +并结合请求重定向、页面白名单配置、本地资源替换、自定义资源加载策略和配置公共请求头等典型实践场景给出实际应用案例, +帮助开发者更好地掌握Web组件请求拦截功能的选择和使用。 +## 效果预览 -#### 安装教程 +| | 请求重定向 | 页面白名单配置 | 本地资源替换 | 自定义资源加载策略 | 配置公共请求头 | +|----------------|--------------------------------------------------|------------------------------------------------------|------------------------------------------------------|------------------------------------------------------|-----------------------------------------------| +| 配置url | ![image](screenshots/device/RedirectRequest.png) | ![image](screenshots/device/PageWhitelist.png) | ![image](screenshots/device/LocalResource.png) | ![image](screenshots/device/CustomLoading.png) | ![image](screenshots/device/CommonHeader.png) | +| 加载Web页面 | ![image](screenshots/device/RedirectRequestResult.png) | ![image](screenshots/device/PageWhitelistResult.png) | ![image](screenshots/device/LocalResourceResult.png) | ![image](screenshots/device/CustomLoadingResult.png) | ![image](screenshots/device/CommonHeaderResult.png) | -1. xxxx -2. xxxx -3. xxxx +## 安装说明 -#### 使用说明 +为清晰地展示配置公共请求头场景下的响应信息,本示例搭建了一个本地服务端。将原始请求添加公共请求头后,转发到本地服务端,本地服务端会将该请求的请求头信息作为响应,返回给客户端。开发者可参考如下步骤启动本地服务端。 -1. xxxx -2. xxxx -3. xxxx +1. 搭建Node.js环境:本示例的服务端是基于Node.js实现的,如果本地已有Node.js环境可以跳过此步骤。 + 1. 检查本地Node.js环境:在本示例工程的根目录下打开DevEco Studio的Terminal,执行`node -v`命令,如果可以看到版本信息,说明已经具备Node.js环境。 + + ![image](screenshots/readme/node_version.png) + 2. 如果本地没有Node.js环境,可以去Node.js官网上下载所需版本进行安装配置。 + 3. 配置完环境变量后,重新打开Terminal,输入`node -v`命令,如果可以看到版本信息,说明已安装成功。 -#### 参与贡献 +2. 构建局域网环境:测试配置公共请求头场景时要确保运行服务端代码的电脑和测试机连接的是同一局域网。可以开一个个人热点,然后将测试机和运行服务端代码的电脑都连接该热点进行测试。 -1. Fork 本仓库 -2. 新建 Feat_xxx 分支 -3. 提交代码 -4. 新建 Pull Request +3. 运行服务端代码:在根目录下打开DevEco Studio的Terminal,执行`hvigorw startHeaderServer`命令,启动本地服务器。 + + ![image](screenshots/readme/start_server.png) +4. 连接服务器地址:将Terminal中Server URL后面的URL复制到[src/main/ets/common/CommonConstants.ets](./entry/src/main/ets/common/CommonConstants.ets)文件下的COMMON_HEADER_REQUEST_URL中,保存好后即可运行本工程进行测试。 -#### 特技 + ![image](screenshots/readme/server_url.png) -1. 使用 Readme\_XXX.md 来支持不同的语言,例如 Readme\_en.md, Readme\_zh.md -2. Gitee 官方博客 [blog.gitee.com](https://blog.gitee.com) -3. 你可以 [https://gitee.com/explore](https://gitee.com/explore) 这个地址来了解 Gitee 上的优秀开源项目 -4. [GVP](https://gitee.com/gvp) 全称是 Gitee 最有价值开源项目,是综合评定出的优秀开源项目 -5. Gitee 官方提供的使用手册 [https://gitee.com/help](https://gitee.com/help) -6. Gitee 封面人物是一档用来展示 Gitee 会员风采的栏目 [https://gitee.com/gitee-stars/](https://gitee.com/gitee-stars/) + 注:也可以直接运行本工程,在配置公共请求头页面的输入框中,手动输入运行服务端代码电脑的IP地址后进行访问请求。可以在Terminal中Local Network IP后面读取IP地址,或者在命令行工具中通过`ipconfig`命令查看IP地址。 + + ![image](screenshots/readme/local_network_ip.png) + +5. 重启本地服务器:执行`hvigorw restartHeaderServer`命令。 + +6. 关闭本地服务器:执行`hvigorw stopHeaderServer`命令。 + +## 使用说明 + +1. 进入请求重定向页面,在输入框中配置原始请求url和重定向url(已设置默认值),点击“加载Web网页”按钮发出请求,通过弹窗显示重定向url的请求响应。 + +2. 进入页面白名单配置页面,在输入框中配置白名单url和加载url(已设置默认值),点击“加载Web网页”按钮发出请求。如果加载url在白名单中,则通过弹窗显示请求响应;否则,需要授权跳转浏览器加载请求响应。 + +3. 进入本地资源替换页面,在输入框中配置请求url(已设置默认值),点击“加载Web网页”按钮发出请求。如果请求url符合页面下方说明的预置条件,则通过弹窗显示本地html页面;否则,通过弹窗正常加载url的请求响应。 + +4. 进入自定义资源加载策略页面,在输入框中配置请求url(已设置默认值),点击“加载Web网页”按钮发出请求。如果在Wi-Fi网络环境下,则通过弹窗显示请求url的图片资源;否则,通过弹窗显示本地占位图。 + +5. 进入配置公共请求头页面,在输入框中配置请求url(已设置默认值),点击“加载Web网页”按钮发出请求,通过弹窗显示添加公共请求头后的数据响应。 + +## 工程目录 + +``` +├──entry +│ └──src +│ └──main +│ ├──ets +│ │ ├──common +│ │ │ ├──utils +│ │ │ │ ├──Logger.ets // 日志工具类 +│ │ │ │ └──RcpRequestForwarder.ets // RCP请求转发工具类 +│ │ │ └──CommonConstants.ets // 静态常量数据 +│ │ ├──component +│ │ │ ├──NavAreaComponent.ets // 跳转下一级页面导航组件 +│ │ │ ├──UrlInputComponent.ets // url输入框组件 +│ │ │ └──WhitelistDialog.ets // 非白名单url跳转浏览器弹窗 +│ │ ├──entryability +│ │ │ └──EntryAbility.ets // 程序入口 +│ │ ├──interceptors +│ │ │ ├──CommonHeaderInterceptor // 配置公共请求头拦截器 +│ │ │ │ ├──model +│ │ │ │ │ └──CommonHeaderModel.ets // 配置公共请求头数据及相关处理 +│ │ │ │ ├──view +│ │ │ │ │ └──CommonHeaderView.ets // 配置公共请求头UI界面 +│ │ │ │ └──viewmodel +│ │ │ │ └──CommonHeaderViewModel.ets // 配置公共请求头UI和数据交互管理 +│ │ │ ├──CustomLoadingStrategyInterceptor // 自定义资源加载拦截器 +│ │ │ │ ├──model +│ │ │ │ │ └──CustomLoadingStrategyModel.ets // 自定义资源加载数据及相关处理 +│ │ │ │ ├──view +│ │ │ │ │ └──CustomLoadingStrategyView.ets // 自定义资源加载UI界面 +│ │ │ │ └──viewmodel +│ │ │ │ └──CustomLoadingStrategyViewModel.ets // 自定义资源加载UI和数据交互管理 +│ │ │ ├──LocalResourceInterceptor // 本地资源替换拦截器 +│ │ │ │ ├──model +│ │ │ │ │ └──LocalResourceModel.ets // 本地资源替换数据及相关处理 +│ │ │ │ ├──view +│ │ │ │ │ └──LocalResourceView.ets // 本地资源替换UI界面 +│ │ │ │ └──viewmodel +│ │ │ │ └──LocalResourceViewModel.ets // 本地资源替换UI和数据交互管理 +│ │ │ ├──PageWhitelistInterceptor // 页面白名单配置拦截器 +│ │ │ │ ├──model +│ │ │ │ │ └──PageWhitelistModel.ets // 页面白名单配置数据及相关处理 +│ │ │ │ ├──view +│ │ │ │ │ └──PageWhitelistView.ets // 页面白名单配置UI界面 +│ │ │ │ └──viewmodel +│ │ │ │ └──PageWhitelistViewModel.ets // 页面白名单配置UI和数据交互管理 +│ │ │ └──RedirectRequestInterceptor // 请求重定向拦截器 +│ │ │ ├──model +│ │ │ │ └──RedirectRequestModel.ets // 请求重定向数据及相关处理 +│ │ │ ├──view +│ │ │ │ └──RedirectRequestView.ets // 请求重定向UI界面 +│ │ │ └──viewmodel +│ │ │ └──RedirectRequestViewModel.ets // 请求重定向UI和数据交互管理 +│ │ └──pages +│ │ └──Index.ets // 首页 +│ └──resources // 应用静态资源目录 +├──LocalServer // 本地服务器 +├──scripts +│ └──commandTask.ts // 命令文件 +└──hvigorfile.ts // 构建脚本 +``` + +## 具体实现 + +1. 请求重定向:使用onLoadIntercept()对原始请求url进行拦截,通过WebviewController.load()加载重定向url。 +2. 页面白名单配置:配置信任url白名单,对不信任的url使用onLoadIntercept()进行拦截,弹窗提示并跳转到浏览器中打开。 +3. 本地资源替换:使用onInterceptRequest()对满足预置条件的url进行拦截,并将本地的H5页面设置给WebResourceResponse作为请求响应。 +4. 自定义资源加载策略:使用connect相关接口判断当前是否处于wifi网络条件下,如果在非wifi网络环境下,则使用onInterceptRequest()对jpg和png格式的图片资源url进行拦截,并将本地的占位图设置给WebResourceResponse作为请求响应。 +5. 配置公共请求头:使用WebSchemeHandler()对网络请求进行拦截,添加公共请求头后,使用RCP将请求转发到本地服务端。 + +## 相关权限 + +允许使用Internet网络权限:ohos.permission.INTERNET。 + +允许应用获取数据网络信息权限:ohos.permission.GET_NETWORK_INFO。 + +## 约束与限制 + +1. 本示例仅支持标准系统上运行,支持设备:华为手机。 + +2. HarmonyOS系统:HarmonyOS 6.0.0 Release及以上。 + +3. DevEco Studio版本:DevEco Studio 6.0.0 Release及以上。 + +4. HarmonyOS SDK版本:HarmonyOS 6.0.0 Release SDK及以上。 \ No newline at end of file diff --git a/build-profile.json5 b/build-profile.json5 new file mode 100644 index 0000000000000000000000000000000000000000..c4e8cef12735e3a7929c1740117603c46528ebe4 --- /dev/null +++ b/build-profile.json5 @@ -0,0 +1,42 @@ +{ + "app": { + "signingConfigs": [], + "products": [ + { + "name": "default", + "signingConfig": "default", + "targetSdkVersion": "6.0.0(20)", + "compatibleSdkVersion": "6.0.0(20)", + "runtimeOS": "HarmonyOS", + "buildOption": { + "strictMode": { + "caseSensitiveCheck": true, + "useNormalizedOHMUrl": true + } + } + } + ], + "buildModeSet": [ + { + "name": "debug", + }, + { + "name": "release" + } + ] + }, + "modules": [ + { + "name": "entry", + "srcPath": "./entry", + "targets": [ + { + "name": "default", + "applyToProducts": [ + "default" + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/code-linter.json5 b/code-linter.json5 new file mode 100644 index 0000000000000000000000000000000000000000..073990fa45394e1f8e85d85418ee60a8953f9b99 --- /dev/null +++ b/code-linter.json5 @@ -0,0 +1,32 @@ +{ + "files": [ + "**/*.ets" + ], + "ignore": [ + "**/src/ohosTest/**/*", + "**/src/test/**/*", + "**/src/mock/**/*", + "**/node_modules/**/*", + "**/oh_modules/**/*", + "**/build/**/*", + "**/.preview/**/*" + ], + "ruleSet": [ + "plugin:@performance/recommended", + "plugin:@typescript-eslint/recommended" + ], + "rules": { + "@security/no-unsafe-aes": "error", + "@security/no-unsafe-hash": "error", + "@security/no-unsafe-mac": "warn", + "@security/no-unsafe-dh": "error", + "@security/no-unsafe-dsa": "error", + "@security/no-unsafe-ecdsa": "error", + "@security/no-unsafe-rsa-encrypt": "error", + "@security/no-unsafe-rsa-sign": "error", + "@security/no-unsafe-rsa-key": "error", + "@security/no-unsafe-dsa-key": "error", + "@security/no-unsafe-dh-key": "error", + "@security/no-unsafe-3des": "error" + } +} \ No newline at end of file diff --git a/entry/.gitignore b/entry/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..e2713a2779c5a3e0eb879efe6115455592caeea5 --- /dev/null +++ b/entry/.gitignore @@ -0,0 +1,6 @@ +/node_modules +/oh_modules +/.preview +/build +/.cxx +/.test \ No newline at end of file diff --git a/entry/build-profile.json5 b/entry/build-profile.json5 new file mode 100644 index 0000000000000000000000000000000000000000..d565b667212016cd3633190b21b6664c34a0654d --- /dev/null +++ b/entry/build-profile.json5 @@ -0,0 +1,33 @@ +{ + "apiType": "stageMode", + "buildOption": { + "resOptions": { + "copyCodeResource": { + "enable": false + } + } + }, + "buildOptionSet": [ + { + "name": "release", + "arkOptions": { + "obfuscation": { + "ruleOptions": { + "enable": true, + "files": [ + "./obfuscation-rules.txt" + ] + } + } + } + }, + ], + "targets": [ + { + "name": "default" + }, + { + "name": "ohosTest", + } + ] +} \ No newline at end of file diff --git a/entry/hvigorfile.ts b/entry/hvigorfile.ts new file mode 100644 index 0000000000000000000000000000000000000000..b0e3a1ab98a91bc918d6404b2413111a5011f14a --- /dev/null +++ b/entry/hvigorfile.ts @@ -0,0 +1,6 @@ +import { hapTasks } from '@ohos/hvigor-ohos-plugin'; + +export default { + system: hapTasks, /* Built-in plugin of Hvigor. It cannot be modified. */ + plugins: [] /* Custom plugin to extend the functionality of Hvigor. */ +} \ No newline at end of file diff --git a/entry/obfuscation-rules.txt b/entry/obfuscation-rules.txt new file mode 100644 index 0000000000000000000000000000000000000000..272efb6ca3f240859091bbbfc7c5802d52793b0b --- /dev/null +++ b/entry/obfuscation-rules.txt @@ -0,0 +1,23 @@ +# Define project specific obfuscation rules here. +# You can include the obfuscation configuration files in the current module's build-profile.json5. +# +# For more details, see +# https://developer.huawei.com/consumer/cn/doc/harmonyos-guides-V5/source-obfuscation-V5 + +# Obfuscation options: +# -disable-obfuscation: disable all obfuscations +# -enable-property-obfuscation: obfuscate the property names +# -enable-toplevel-obfuscation: obfuscate the names in the global scope +# -compact: remove unnecessary blank spaces and all line feeds +# -remove-log: remove all console.* statements +# -print-namecache: print the name cache that contains the mapping from the old names to new names +# -apply-namecache: reuse the given cache file + +# Keep options: +# -keep-property-name: specifies property names that you want to keep +# -keep-global-name: specifies names that you want to keep in the global scope + +-enable-property-obfuscation +-enable-toplevel-obfuscation +-enable-filename-obfuscation +-enable-export-obfuscation \ No newline at end of file diff --git a/entry/oh-package.json5 b/entry/oh-package.json5 new file mode 100644 index 0000000000000000000000000000000000000000..248c3b7541a589682a250f86a6d3ecf7414d2d6a --- /dev/null +++ b/entry/oh-package.json5 @@ -0,0 +1,10 @@ +{ + "name": "entry", + "version": "1.0.0", + "description": "Please describe the basic information.", + "main": "", + "author": "", + "license": "", + "dependencies": {} +} + diff --git a/entry/src/main/ets/Interceptors/CommonHeaderInterceptor/model/CommonHeaderModel.ets b/entry/src/main/ets/Interceptors/CommonHeaderInterceptor/model/CommonHeaderModel.ets new file mode 100644 index 0000000000000000000000000000000000000000..75c9471409a5e3508775f3840b127962555ab3fe --- /dev/null +++ b/entry/src/main/ets/Interceptors/CommonHeaderInterceptor/model/CommonHeaderModel.ets @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2025 Huawei Device Co., Ltd. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { webview } from '@kit.ArkWeb'; +import { BusinessError } from '@kit.BasicServicesKit'; +import { RcpRequestForwarder } from '../../../common/utils/RcpRequestForwarder'; +import Logger from '../../../common/utils/Logger'; + +const TAG = '[CommonHeaderModel]'; + +/** + * Model for CommonHeader scene + */ +export class CommonHeaderModel { + private forwarder: RcpRequestForwarder; + + constructor(forwarder: RcpRequestForwarder) { + this.forwarder = forwarder; + } + + /** + * Checks if the request URL needs to be intercepted + * Intercepts requests to the header server (by port 8081 or common IP patterns) + */ + shouldInterceptUrl(url: string): boolean { + // Intercept requests to headerServer on port 8081 + // Supports localhost, 127.0.0.1, and local network IPs (192.168.x.x, 10.x.x.x, 172.16-31.x.x) + return url.includes(':8081') && ( + url.includes('localhost:8081') || + url.includes('127.0.0.1:8081') || + url.match(/192\.168\.\d+\.\d+:8081/) !== null || + url.match(/10\.\d+\.\d+\.\d+:8081/) !== null || + url.match(/172\.(1[6-9]|2[0-9]|3[0-1])\.\d+\.\d+:8081/) !== null + ); + } + + /** + * Forwards the intercepted request to the header server + */ + forwardRequest(request: webview.WebSchemeHandlerRequest, resourceHandler: webview.WebResourceHandler): boolean { + try { + const requestUrl = request.getRequestUrl(); + Logger.info(TAG, `Forwarding request to headerServer: ${requestUrl}`); + this.forwarder.forwardRequest(request, resourceHandler); + return true; + } catch (error) { + let err = error as BusinessError; + Logger.error(TAG, `Forward request failed, errCode: ${err.code}, errMessage: ${err.message}`); + return false; + } + } + + /** + * Processes the intercepted request + * Returns true if request was handled, false otherwise + */ + processRequest(request: webview.WebSchemeHandlerRequest, resourceHandler: webview.WebResourceHandler): boolean { + const requestUrl = request.getRequestUrl(); + Logger.info(TAG, `Intercepting request: ${requestUrl}`); + + if (this.shouldInterceptUrl(requestUrl)) { + return this.forwardRequest(request, resourceHandler); + } + return false; + } +} diff --git a/entry/src/main/ets/Interceptors/CommonHeaderInterceptor/view/CommonHeaderView.ets b/entry/src/main/ets/Interceptors/CommonHeaderInterceptor/view/CommonHeaderView.ets new file mode 100644 index 0000000000000000000000000000000000000000..7a60e277a946351cce7f2273532909407805182a --- /dev/null +++ b/entry/src/main/ets/Interceptors/CommonHeaderInterceptor/view/CommonHeaderView.ets @@ -0,0 +1,126 @@ +/* + * Copyright (c) 2025 Huawei Device Co., Ltd. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { webview } from '@kit.ArkWeb'; +import { BusinessError } from '@kit.BasicServicesKit'; +import { CommonConstants } from '../../../common/CommonConstants'; +import { UrlInputComponent } from '../../../component/UrlInputComponent'; +import { CommonHeaderViewModel } from '../viewmodel/CommonHeaderViewModel'; +import { RcpForwarderConfig } from '../../../common/utils/RcpRequestForwarder'; +import Logger from '../../../common/utils/Logger'; + +const TAG = '[CommonHeader]'; + +@Builder +export function CommonHeaderBuilder() { + CommonHeader() +} + +@Component +struct CommonHeader { + @State requestUrl: ResourceStr = CommonConstants.COMMON_HEADER_REQUEST_URL; + @State isLoading: boolean = false; + controller: webview.WebviewController = new webview.WebviewController(); + schemeHandler: webview.WebSchemeHandler = new webview.WebSchemeHandler(); + viewModel?: CommonHeaderViewModel; + // Configure common header + private readonly COMMON_HEADER_NAME: string = 'X-Custom-Header'; + private readonly COMMON_HEADER_VALUE: string = 'HarmonyOS-HeaderRequest-Proxy'; + + aboutToAppear(): void { + // Initialize RCP forwarder + const forwarderConfig: RcpForwarderConfig = { + commonHeaderName: this.COMMON_HEADER_NAME, + commonHeaderValue: this.COMMON_HEADER_VALUE + }; + // Initialize common header interceptor + this.viewModel = new CommonHeaderViewModel(this.schemeHandler, forwarderConfig); + } + + build() { + NavDestination() { + Scroll() { + Column() { + UrlInputComponent({ + url: this.requestUrl, + urlTitle: $r('app.string.request_url'), + inputAlert: $r('app.string.url_input_alert') + }) + + Blank() + .layoutWeight(1) + + Button($r('app.string.load_web_page')) + .height($r('app.float.load_web_button_height')) + .width(CommonConstants.FULL_PERCENT) + .margin($r('app.float.load_web_button_margin')) + .constraintSize({ maxWidth: CommonConstants.FULL_PERCENT }) + .onClick(() => { + this.isLoading = true; + }) + } + .padding({ top: $r('app.float.url_input_top_padding') }) + .layoutWeight(1) + .width(CommonConstants.FULL_PERCENT) + .bindSheet($$this.isLoading, this.WebLoading(), { + height: CommonConstants.FULL_PERCENT, + backgroundColor: Color.White, + title: { + title: CommonConstants.WEB_PAGE_NAME + } + }) + } + } + .title($r('app.string.configure_common_request_header')) + .backgroundColor($r('app.color.home_page_background')) + .onBackPressed(() => { + // If there is a previous web page, go back to the previous page; otherwise, close the web page. + if (!this.isLoading) { + return false; + } + let isLastPage = false; + try { + isLastPage = this.controller.accessStep(-1); + if (isLastPage) { + this.controller.backward(); + return true; + } + return false; + } catch (error) { + let err = error as BusinessError; + Logger.error(TAG, `Get the accessStep failed, errCode: ${err.code}, errMessage: ${err.message}.`); + return false; + } + }) + } + + /** + * Builds the web view sheet that adding common headers via SchemeHandler. + */ + @Builder + WebLoading() { + // Use the configured requestUrl (should be set to local network IP) + Web({ src: this.requestUrl, controller: this.controller }) + .onControllerAttached(() => { + // Use SchemeHandler interceptor + this.viewModel?.onControllerAttached(this.controller); + }) + .javaScriptAccess(true) + .fileAccess(true) + .domStorageAccess(true) + .imageAccess(true) + .backgroundColor($r('sys.color.background_secondary')) + } +} \ No newline at end of file diff --git a/entry/src/main/ets/Interceptors/CommonHeaderInterceptor/viewmodel/CommonHeaderViewModel.ets b/entry/src/main/ets/Interceptors/CommonHeaderInterceptor/viewmodel/CommonHeaderViewModel.ets new file mode 100644 index 0000000000000000000000000000000000000000..15d80bbe7d560e0f2b6e024368861bff9845cef5 --- /dev/null +++ b/entry/src/main/ets/Interceptors/CommonHeaderInterceptor/viewmodel/CommonHeaderViewModel.ets @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2025 Huawei Device Co., Ltd. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { webview } from '@kit.ArkWeb'; +import { BusinessError } from '@kit.BasicServicesKit'; +import { CommonHeaderModel } from '../model/CommonHeaderModel'; +import { RcpForwarderConfig, RcpRequestForwarder } from '../../../common/utils/RcpRequestForwarder'; +import Logger from '../../../common/utils/Logger'; + +const TAG = '[CommonHeaderViewModel]'; + +/** + * ViewModel for CommonHeader scene + */ +export class CommonHeaderViewModel { + private schemeHandler: webview.WebSchemeHandler; + private model: CommonHeaderModel; + + constructor( + schemeHandler: webview.WebSchemeHandler, + forwarderConfig: RcpForwarderConfig, + forwarder?: RcpRequestForwarder + ) { + this.schemeHandler = schemeHandler; + const resolvedForwarder = forwarder ?? new RcpRequestForwarder(forwarderConfig); + this.model = new CommonHeaderModel(resolvedForwarder); + } + + /** + * Sets up the interceptor when controller is attached + */ + onControllerAttached(controller: webview.WebviewController): void { + try { + // Bind interceptor to HTTP + controller.setWebSchemeHandler('http', this.schemeHandler); + + // Set up request interceptor + this.schemeHandler.onRequestStart((request: webview.WebSchemeHandlerRequest, + resourceHandler: webview.WebResourceHandler) => { + // Process request + const handled = this.model.processRequest(request, resourceHandler); + return handled; + }); + + Logger.info(TAG, `HTTP protocol interceptor set up`); + } catch (error) { + let err = error as BusinessError; + Logger.error(TAG, `Setup failed, errCode: ${err.code}, errMessage: ${err.message}`); + } + } +} diff --git a/entry/src/main/ets/Interceptors/CustomLoadingStrategyInterceptor/model/CustomLoadingStrategyModel.ets b/entry/src/main/ets/Interceptors/CustomLoadingStrategyInterceptor/model/CustomLoadingStrategyModel.ets new file mode 100644 index 0000000000000000000000000000000000000000..ddee2df1d4f0978b63384c55936fce1437530481 --- /dev/null +++ b/entry/src/main/ets/Interceptors/CustomLoadingStrategyInterceptor/model/CustomLoadingStrategyModel.ets @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2025 Huawei Device Co., Ltd. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { connection } from '@kit.NetworkKit'; +import { BusinessError } from '@kit.BasicServicesKit'; +import { CommonConstants } from '../../../common/CommonConstants'; +import Logger from '../../../common/utils/Logger'; + +const TAG = '[CustomLoadingStrategyModel]'; + +/** + * Model for CustomLoadingStrategy scene + */ +export class CustomLoadingStrategyModel { + private imageFormatList: string[] = []; + + /** + * Sets the supported image formats that should be replaced + */ + setImageFormatList(formats: string[]): void { + this.imageFormatList = formats; + } + + /** + * Checks if currently connected to a Wi-Fi network + */ + isWifiNetwork(): boolean { + try { + const netHandle = connection.getDefaultNetSync(); + const netData = connection.getNetCapabilitiesSync(netHandle); + return netData.bearerTypes.includes(connection.NetBearType.BEARER_WIFI); + } catch (error) { + let err = error as BusinessError; + Logger.error(TAG, `Get the network info failed, errCode: ${err.code}, errMessage: ${err.message}.`); + return false; + } + } + + /** + * Checks if a URL request is for an image + */ + isImageRequestUrl(url: string): boolean { + for (let format of this.imageFormatList) { + if (url.endsWith(format)) { + return true; + } + } + return false; + } + + /** + * Creates a placeholder image response for non-WiFi networks + */ + createPlaceholderResponse(): WebResourceResponse { + const response = new WebResourceResponse(); + response.setResponseHeader([{ + headerKey: 'Connection', + headerValue: 'keep-alive' + }]); + response.setResponseData($rawfile(CommonConstants.IMAGE_NO_WLAN)); + response.setResponseEncoding('utf-8'); + response.setResponseMimeType('image/png'); + response.setResponseCode(200); + response.setReasonMessage('OK'); + response.setResponseIsReady(true); + return response; + } + + /** + * Processes the intercepted request and returns appropriate response + * Returns null if request should be allowed through + */ + processRequest(event: OnInterceptRequestEvent | null): WebResourceResponse | null { + if (!event || !event.request) { + return null; + } + + // If WiFi network, allow original request + if (this.isWifiNetwork()) { + return null; + } + + const requestUrl = event.request.getRequestUrl(); + // Only intercept image requests + if (!this.isImageRequestUrl(requestUrl)) { + return null; // Not an image, allow original request + } + + // Replace with placeholder image + return this.createPlaceholderResponse(); + } +} diff --git a/entry/src/main/ets/Interceptors/CustomLoadingStrategyInterceptor/view/CustomLoadingStrategyView.ets b/entry/src/main/ets/Interceptors/CustomLoadingStrategyInterceptor/view/CustomLoadingStrategyView.ets new file mode 100644 index 0000000000000000000000000000000000000000..165d9c00143a569cdca101960c50d31c31594b6d --- /dev/null +++ b/entry/src/main/ets/Interceptors/CustomLoadingStrategyInterceptor/view/CustomLoadingStrategyView.ets @@ -0,0 +1,129 @@ +/* + * Copyright (c) 2025 Huawei Device Co., Ltd. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { BusinessError } from '@kit.BasicServicesKit'; +import { webview } from '@kit.ArkWeb'; +import { CommonConstants } from '../../../common/CommonConstants'; +import { UrlInputComponent } from '../../../component/UrlInputComponent'; +import { CustomLoadingStrategyViewModel } from '../viewmodel/CustomLoadingStrategyViewModel'; +import Logger from '../../../common/utils/Logger'; + +const TAG = '[CustomLoadingStrategy]'; + +@Builder +export function CustomLoadingStrategyBuilder() { + CustomLoadingStrategy() +} + +@Component +struct CustomLoadingStrategy { + @State requestUrl: ResourceStr = CommonConstants.IMAGE_REQUEST_URL; + @State isLoading: boolean = false; + controller: webview.WebviewController = new webview.WebviewController(); + viewModel?: CustomLoadingStrategyViewModel; + imgFormatList: Array = CommonConstants.IMAGE_FORMAT_LIST; + + aboutToAppear(): void { + // Initialize custom loading strategy interceptor + this.viewModel = new CustomLoadingStrategyViewModel(); + } + + build() { + NavDestination() { + Scroll() { + Column() { + UrlInputComponent({ + url: this.requestUrl, + urlTitle: $r('app.string.request_url'), + inputAlert: $r('app.string.url_input_alert') + }) + + Blank() + .layoutWeight(1) + + Text($r('app.string.custom_resource_loading_explain')) + .fontSize($r('app.float.url_input_explain_font_size')) + .fontColor($r('app.color.explain_font_color')) + .margin($r('app.float.url_input_explain_margin')) + + Button($r('app.string.load_web_page')) + .height($r('app.float.load_web_button_height')) + .width(CommonConstants.FULL_PERCENT) + .margin($r('app.float.load_web_button_margin')) + .constraintSize({ maxWidth: CommonConstants.FULL_PERCENT }) + .onClick(() => { + this.isLoading = true; + }) + } + .padding({ top: $r('app.float.url_input_top_padding') }) + .layoutWeight(1) + .width(CommonConstants.FULL_PERCENT) + .bindSheet($$this.isLoading, this.WebLoading(), { + height: CommonConstants.FIFTY_PERCENT, + backgroundColor: Color.White, + title: { + title: CommonConstants.WEB_PAGE_NAME + } + }) + } + } + .title($r('app.string.custom_resource_loading_strategy')) + .backgroundColor($r('app.color.home_page_background')) + .onBackPressed(() => { + // If there is a previous web page, go back to the previous page; otherwise, close the web page. + if (!this.isLoading) { + return false; + } + let isLastPage = false; + try { + isLastPage = this.controller.accessStep(-1); + if (isLastPage) { + this.controller.backward(); + return true; + } + return false; + } catch (error) { + let err = error as BusinessError; + Logger.error(TAG, `Get the accessStep failed, errCode: ${err.code}, errMessage: ${err.message}.`); + return false; + } + }) + } + + /** + * Builds the web view sheet with custom loading strategy via onInterceptRequest. + */ + @Builder + WebLoading() { + Column() { + Web({ src: this.requestUrl, controller: this.controller }) + .onInterceptRequest((event) => { + // Update image format list before intercepting + this.viewModel?.setImageFormatList(this.imgFormatList); + // Use request interceptor + return this.viewModel?.onInterceptRequest(event) ?? null; + }) + .javaScriptAccess(true) + .fileAccess(true) + .domStorageAccess(true) + .imageAccess(true) + .backgroundColor($r('sys.color.background_secondary')) + .height(CommonConstants.SEVENTY_FIVE_PERCENT) + } + .height(CommonConstants.FULL_PERCENT) + .width(CommonConstants.FULL_PERCENT) + .justifyContent(FlexAlign.Center) + } +} \ No newline at end of file diff --git a/entry/src/main/ets/Interceptors/CustomLoadingStrategyInterceptor/viewmodel/CustomLoadingStrategyViewModel.ets b/entry/src/main/ets/Interceptors/CustomLoadingStrategyInterceptor/viewmodel/CustomLoadingStrategyViewModel.ets new file mode 100644 index 0000000000000000000000000000000000000000..89e59b89de281a7bd907b09dd1b2c20952de3141 --- /dev/null +++ b/entry/src/main/ets/Interceptors/CustomLoadingStrategyInterceptor/viewmodel/CustomLoadingStrategyViewModel.ets @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2025 Huawei Device Co., Ltd. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { CustomLoadingStrategyModel } from '../model/CustomLoadingStrategyModel'; + +/** + * ViewModel for CustomLoadingStrategy scene + */ +export class CustomLoadingStrategyViewModel { + private model: CustomLoadingStrategyModel; + + constructor() { + this.model = new CustomLoadingStrategyModel(); + } + + /** + * Updates the image format list + */ + setImageFormatList(formats: string[]): void { + this.model.setImageFormatList(formats); + } + + /** + * Handles interception logic + */ + onInterceptRequest(event: OnInterceptRequestEvent | null): WebResourceResponse | null { + return this.model.processRequest(event); + } +} diff --git a/entry/src/main/ets/Interceptors/LocalResourceInterceptor/model/LocalResourceModel.ets b/entry/src/main/ets/Interceptors/LocalResourceInterceptor/model/LocalResourceModel.ets new file mode 100644 index 0000000000000000000000000000000000000000..38297f8a52e0706e1e6b352c62519ddc7e8154cf --- /dev/null +++ b/entry/src/main/ets/Interceptors/LocalResourceInterceptor/model/LocalResourceModel.ets @@ -0,0 +1,102 @@ +/* + * Copyright (c) 2025 Huawei Device Co., Ltd. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Model for LocalResource scene + */ +export class LocalResourceModel { + private schemeMap: Map; + private mimeTypeMap: Map; + + constructor(schemeMap: Map, mimeTypeMap: Map) { + this.schemeMap = schemeMap; + this.mimeTypeMap = mimeTypeMap; + } + + /** + * Updates the scheme and mime type mappings + */ + updateMappings(schemeMap: Map, mimeTypeMap: Map): void { + this.schemeMap = schemeMap; + this.mimeTypeMap = mimeTypeMap; + } + + /** + * Gets the matching URL scheme key from the map + * Finds the longest matching key to prioritize specific paths over general ones + */ + getUrlSchemeFromMap(prefix: string): string { + let matchedKey: string = ''; + let maxLength: number = 0; + + // Find the longest matching key to prioritize specific paths over general ones + for (let key of this.schemeMap.keys()) { + if (prefix.startsWith(key) && key.length > maxLength) { + matchedKey = key; + maxLength = key.length; + } + } + + return matchedKey; + } + + /** + * Creates a response with local file data + */ + createLocalResourceResponse(rawfileName: string, mimeType: string): WebResourceResponse { + const response = new WebResourceResponse(); + response.setResponseHeader([{ + headerKey: 'Connection', + headerValue: 'keep-alive' + }]); + response.setResponseData($rawfile(rawfileName)); + response.setResponseEncoding('utf-8'); + response.setResponseMimeType(mimeType); + response.setResponseCode(200); + response.setReasonMessage('OK'); + response.setResponseIsReady(true); + return response; + } + + /** + * Processes the intercepted request and returns local resource response if applicable + * Returns null if request should be allowed through + */ + processRequest(event: OnInterceptRequestEvent | null): WebResourceResponse | null { + if (!event || !event.request) { + return null; + } + + const requestUrl = event.request.getRequestUrl(); + const key = this.getUrlSchemeFromMap(requestUrl); + + if (key.length === 0) { + return null; // No match, allow original request + } + + const rawfileName = this.schemeMap.get(key); + if (!rawfileName) { + return null; // Invalid mapping, allow original request + } + + const mimeType = this.mimeTypeMap.get(rawfileName); + if (!mimeType) { + return null; // Invalid mapping, allow original request + } + + // Create response with local file + return this.createLocalResourceResponse(rawfileName, mimeType); + } +} diff --git a/entry/src/main/ets/Interceptors/LocalResourceInterceptor/view/LocalResourceView.ets b/entry/src/main/ets/Interceptors/LocalResourceInterceptor/view/LocalResourceView.ets new file mode 100644 index 0000000000000000000000000000000000000000..18666e830bed1b7521e0ca135b2788077f3552b2 --- /dev/null +++ b/entry/src/main/ets/Interceptors/LocalResourceInterceptor/view/LocalResourceView.ets @@ -0,0 +1,146 @@ +/* + * Copyright (c) 2025 Huawei Device Co., Ltd. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { webview } from '@kit.ArkWeb'; +import { BusinessError } from '@kit.BasicServicesKit'; +import { LocalResourceViewModel } from '../viewmodel/LocalResourceViewModel'; +import { CommonConstants } from '../../../common/CommonConstants'; +import { UrlInputComponent } from '../../../component/UrlInputComponent'; +import Logger from '../../../common/utils/Logger'; + +const TAG = '[LocalResource]'; + +@Builder +export function LocalResourceBuilder() { + LocalResource() +} + +@Component +struct LocalResource { + @State requestUrl: ResourceStr = CommonConstants.EXAMPLE_URL; + @State isLoading: boolean = false; + controller: webview.WebviewController = new webview.WebviewController(); + viewModel?: LocalResourceViewModel; + // Map between domain names and local files + schemeMap = new Map([ + ['https://www.example.com/', 'index.html'], + ['https://www.example.com/mountain.png', 'mountain.png'] + ]); + // Map between local files and format mimeType + mimeTypeMap = new Map([ + ['index.html', 'text/html'], + ['mountain.png', 'image/png'] + ]); + + aboutToAppear(): void { + // Initialize local Resource interceptor + this.viewModel = new LocalResourceViewModel(this.schemeMap, this.mimeTypeMap); + } + + build() { + NavDestination() { + Scroll() { + Column() { + UrlInputComponent({ + url: this.requestUrl, + urlTitle: $r('app.string.request_url'), + inputAlert: $r('app.string.url_input_alert') + }) + + Blank() + .layoutWeight(1) + + Text($r('app.string.local_resource_replace_explain')) + .fontSize($r('app.float.url_input_explain_font_size')) + .fontColor($r('app.color.explain_font_color')) + .margin($r('app.float.url_input_explain_margin')) + + Button($r('app.string.load_web_page')) + .height($r('app.float.load_web_button_height')) + .width(CommonConstants.FULL_PERCENT) + .margin($r('app.float.load_web_button_margin')) + .constraintSize({ maxWidth: CommonConstants.FULL_PERCENT }) + .onClick(() => { + if (this.requestUrl.toString().length === 0) { + try { + this.getUIContext() + .getPromptAction() + .showToast({ + message: $r('app.string.url_can_not_be_null'), + duration: CommonConstants.TOAST_DURATION + }); + return; + } catch (error) { + const err = error as BusinessError; + Logger.error(TAG, `Show toast failed, errCode: ${err.code}, errMessage: ${err.message}.`); + } + } + this.isLoading = true; + }) + } + .padding({ top: $r('app.float.url_input_top_padding') }) + .layoutWeight(1) + .width(CommonConstants.FULL_PERCENT) + .bindSheet($$this.isLoading, this.WebLoading(), { + height: CommonConstants.FIFTY_PERCENT, + backgroundColor: Color.White, + title: { + title: CommonConstants.WEB_PAGE_NAME + } + }) + } + } + .title($r('app.string.local_resource_replacement')) + .backgroundColor($r('app.color.home_page_background')) + .onBackPressed(() => { + // If there is a previous web page, go back to the previous page; otherwise, close the web page. + if (!this.isLoading) { + return false; + } + let isLastPage = false; + try { + isLastPage = this.controller.accessStep(-1); + if (isLastPage) { + this.controller.backward(); + return true; + } + return false; + } catch (error) { + let err = error as BusinessError; + Logger.error(TAG, `Get the accessStep failed, errCode: ${err.code}, errMessage: ${err.message}.`); + return false; + } + }) + } + + /** + * Builds the web view sheet with local resource replacement via onInterceptRequest. + */ + @Builder + WebLoading() { + Web({ src: this.requestUrl, controller: this.controller }) + .onInterceptRequest((event) => { + // Update scheme map before intercepting + this.viewModel?.updateMappings(this.schemeMap, this.mimeTypeMap); + // Use request interceptor + return this.viewModel?.onInterceptRequest(event) ?? null; + }) + .javaScriptAccess(true) + .fileAccess(true) + .domStorageAccess(true) + .imageAccess(true) + .backgroundColor($r('sys.color.background_secondary')) + } +} \ No newline at end of file diff --git a/entry/src/main/ets/Interceptors/LocalResourceInterceptor/viewmodel/LocalResourceViewModel.ets b/entry/src/main/ets/Interceptors/LocalResourceInterceptor/viewmodel/LocalResourceViewModel.ets new file mode 100644 index 0000000000000000000000000000000000000000..4ef4ad47590da64015223b799848bac69ea9d7af --- /dev/null +++ b/entry/src/main/ets/Interceptors/LocalResourceInterceptor/viewmodel/LocalResourceViewModel.ets @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2025 Huawei Device Co., Ltd. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { LocalResourceModel } from '../model/LocalResourceModel'; + +/** + * ViewModel for LocalResource scene + */ +export class LocalResourceViewModel { + private model: LocalResourceModel; + + constructor( + initialSchemeMap?: Map, + initialMimeTypeMap?: Map, + ) { + const schemeMap = initialSchemeMap ?? new Map(); + const mimeTypeMap = initialMimeTypeMap ?? new Map(); + this.model = new LocalResourceModel(schemeMap, mimeTypeMap); + } + + /** + * Updates the mappings + */ + updateMappings(schemeMap: Map, mimeTypeMap: Map): void { + this.model.updateMappings(schemeMap, mimeTypeMap); + } + + /** + * Handles interception logic + */ + onInterceptRequest(event: OnInterceptRequestEvent | null): WebResourceResponse | null { + return this.model.processRequest(event); + } +} diff --git a/entry/src/main/ets/Interceptors/PageWhitelistInterceptor/model/PageWhitelistModel.ets b/entry/src/main/ets/Interceptors/PageWhitelistInterceptor/model/PageWhitelistModel.ets new file mode 100644 index 0000000000000000000000000000000000000000..06aef42a71731e9e9bb14ce25cad6b20a87a3115 --- /dev/null +++ b/entry/src/main/ets/Interceptors/PageWhitelistInterceptor/model/PageWhitelistModel.ets @@ -0,0 +1,121 @@ +/* + * Copyright (c) 2025 Huawei Device Co., Ltd. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { WhitelistDialogHandler, WhitelistDialogConfig } from '../../../component/WhitelistDialog'; + +/** + * Model for PageWhitelist scene + */ +export class PageWhitelistModel { + private whitelistDomains: string[] = []; + private dialogHandler: WhitelistDialogHandler; + private closeWebpageCallback?: () => void; + private allowAllForCurrentLoad: boolean = false; + + constructor(dialogHandler: WhitelistDialogHandler, closeWebpageCallback?: () => void) { + this.dialogHandler = dialogHandler; + this.closeWebpageCallback = closeWebpageCallback; + } + + /** + * Updates the whitelist URLs + */ + setWhitelistUrls(urls: string[]): void { + this.whitelistDomains = urls + .map(url => this.extractDomain(url)) + .filter(domain => domain.length > 0); + } + + /** + * Reset session-level allow flags when starting a new load + */ + resetSession(): void { + this.allowAllForCurrentLoad = false; + } + + + /** + * Checks if a URL is in the whitelist + */ + isUrlInWhitelist(requestUrl: string): boolean { + const requestDomain = this.extractDomain(requestUrl); + if (!requestDomain) { + return false; + } + return this.whitelistDomains.includes(requestDomain); + } + + private extractDomain(url: string): string { + let normalized = url.trim().toLowerCase(); + if (!normalized) { + return ''; + } + normalized = normalized + .replace(/^(?:[a-z0-9+.-]+:)?\/\//, '') // strip protocol-like prefixes + .split(/[/?#]/)[0]; // drop everything after domain + return normalized.replace(/:+$/, '').replace(/\/+$/, ''); + } + + /** + * Creates the dialog configuration for blocking non-whitelisted URLs + */ + createDialogConfig(): WhitelistDialogConfig { + return { + title: $r('app.string.open_in_browser_title'), + message: $r('app.string.open_in_browser_message'), + cancelText: $r('app.string.cancel'), + okText: $r('app.string.ok'), + onCancel: () => { + this.closeWebpageCallback?.(); + }, + onOk: () => { + this.closeWebpageCallback?.(); + }, + onDismiss: () => { + this.closeWebpageCallback?.(); + } + }; + } + + /** + * Shows the dialog prompting the user to open the URL externally + */ + showDialog(requestUrl: string): void { + const config = this.createDialogConfig(); + this.dialogHandler.showDialog(requestUrl, config); + } + + /** + * Processes the load intercept event + * Returns true if loading should be blocked, false to allow + */ + processLoadIntercept(event: OnLoadInterceptEvent): boolean { + const requestUrl = event.data.getRequestUrl(); + + if (this.allowAllForCurrentLoad) { + return false; + } + + // Check if URL is in whitelist + if (this.isUrlInWhitelist(requestUrl)) { + this.allowAllForCurrentLoad = true; + return false; // Allow loading and subsequent requests + } + + // URL not in whitelist, show dialog + this.showDialog(requestUrl); + return true; // Block loading + } +} diff --git a/entry/src/main/ets/Interceptors/PageWhitelistInterceptor/view/PageWhitelistView.ets b/entry/src/main/ets/Interceptors/PageWhitelistInterceptor/view/PageWhitelistView.ets new file mode 100644 index 0000000000000000000000000000000000000000..c9b56e66547c5f7e4e40ce664bfe06d2782bf9f6 --- /dev/null +++ b/entry/src/main/ets/Interceptors/PageWhitelistInterceptor/view/PageWhitelistView.ets @@ -0,0 +1,172 @@ +/* + * Copyright (c) 2025 Huawei Device Co., Ltd. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { webview } from '@kit.ArkWeb'; +import { BusinessError } from '@kit.BasicServicesKit'; +import { CommonConstants } from '../../../common/CommonConstants'; +import { UrlInputComponent } from '../../../component/UrlInputComponent'; +import { WhitelistDialogHandler } from '../../../component/WhitelistDialog'; +import { PageWhitelistViewModel } from '../viewmodel/PageWhitelistViewModel'; +import Logger from '../../../common/utils/Logger'; + +const TAG = '[PageWhitelist]'; + +@Builder +export function PageWhitelistBuilder() { + PageWhitelist() +} + +@Component +struct PageWhitelist { + @Consume('NavPathStack') pageStack: NavPathStack; + @State whitelistUrl: ResourceStr = CommonConstants.DEFAULT_REQUEST_URL; + @State loadingUrl: ResourceStr = CommonConstants.EXAMPLE_URL; + @State isLoading: boolean = false; + whitelistUrlArr: ResourceStr[] = []; + controller: webview.WebviewController = new webview.WebviewController(); + dialogHandler: WhitelistDialogHandler = new WhitelistDialogHandler(); + viewModel?: PageWhitelistViewModel; + + aboutToAppear(): void { + // Initialize dialog handler + this.initializeDialogHandler(); + // Initialize load interceptor + this.viewModel = new PageWhitelistViewModel( + this.dialogHandler, + () => { + this.isLoading = false; + } + ); + } + + /** + * Initialize dialog handler with UI context + */ + private initializeDialogHandler(): void { + try { + const uiContext = this.getUIContext(); + if (uiContext) { + this.dialogHandler.setUIContext(uiContext); + } + } catch (error) { + const err = error as BusinessError; + Logger.error(TAG, `Initialize dialog handler failed, errCode: ${err.code}, errMessage: ${err.message}.`); + } + } + + build() { + NavDestination() { + Scroll() { + Column() { + UrlInputComponent({ + url: this.whitelistUrl, + urlTitle: $r('app.string.white_list_url'), + inputAlert: $r('app.string.url_input_alert') + }) + + UrlInputComponent({ + url: this.loadingUrl, + urlTitle: $r('app.string.loading_url'), + inputAlert: $r('app.string.url_input_alert') + }) + + Blank() + .layoutWeight(1) + + Text($r('app.string.white_list_url_explain')) + .fontSize($r('app.float.url_input_explain_font_size')) + .fontColor($r('app.color.explain_font_color')) + .margin($r('app.float.url_input_explain_margin')) + + Button($r('app.string.load_web_page')) + .height($r('app.float.load_web_button_height')) + .width(CommonConstants.FULL_PERCENT) + .margin($r('app.float.load_web_button_margin')) + .constraintSize({ maxWidth: CommonConstants.FULL_PERCENT }) + .onClick(() => { + if (this.loadingUrl.toString().length === 0) { + try { + this.getUIContext() + .getPromptAction() + .showToast({ + message: $r('app.string.url_can_not_be_null'), + duration: CommonConstants.TOAST_DURATION + }); + return; + } catch (error) { + const err = error as BusinessError; + Logger.error(TAG, `Show toast failed, errCode: ${err.code}, errMessage: ${err.message}.`); + } + } + // Parse URLs in the whitelist. + this.whitelistUrlArr = this.whitelistUrl.toString().split(','); + this.viewModel?.resetSession(); + this.isLoading = true; + }) + } + .padding({ top: $r('app.float.url_input_top_padding') }) + .layoutWeight(1) + .width(CommonConstants.FULL_PERCENT) + .bindSheet($$this.isLoading, this.WebLoading(), { + height: CommonConstants.FULL_PERCENT, + backgroundColor: Color.White, + title: { + title: CommonConstants.WEB_PAGE_NAME + } + }) + } + } + .title($r('app.string.configure_whitelist')) + .backgroundColor($r('app.color.home_page_background')) + .onBackPressed(() => { + // If there is a previous web page, go back to the previous page; otherwise, close the web page. + if (!this.isLoading) { + return false; + } + let isLastPage = false; + try { + isLastPage = this.controller.accessStep(-1); + if (isLastPage) { + this.controller.backward(); + return true; + } + return false; + } catch (error) { + let err = error as BusinessError; + Logger.error(TAG, `Get the accessStep failed, errCode: ${err.code}, errMessage: ${err.message}.`); + return false; + } + }) + } + + /** + * Builds the web view sheet used to validate whitelist access via onLoadIntercept. + */ + @Builder + WebLoading() { + Web({ src: this.loadingUrl, controller: this.controller }) + .onLoadIntercept((event) => { + // Update whitelist URLs before intercepting + this.viewModel?.setWhitelistUrls(this.whitelistUrlArr.map(url => url.toString())); + // Use load interceptor + return this.viewModel?.onLoadIntercept(event) ?? false; + }) + .javaScriptAccess(true) + .fileAccess(true) + .domStorageAccess(true) + .imageAccess(true) + .backgroundColor($r('sys.color.background_secondary')) + } +} \ No newline at end of file diff --git a/entry/src/main/ets/Interceptors/PageWhitelistInterceptor/viewmodel/PageWhitelistViewModel.ets b/entry/src/main/ets/Interceptors/PageWhitelistInterceptor/viewmodel/PageWhitelistViewModel.ets new file mode 100644 index 0000000000000000000000000000000000000000..e1e0486f72fb3a1cb60097f502b1a9aa7b041d6a --- /dev/null +++ b/entry/src/main/ets/Interceptors/PageWhitelistInterceptor/viewmodel/PageWhitelistViewModel.ets @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2025 Huawei Device Co., Ltd. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { PageWhitelistModel } from '../model/PageWhitelistModel'; +import { WhitelistDialogHandler } from '../../../component/WhitelistDialog'; + +/** + * ViewModel for PageWhitelist scene + */ +export class PageWhitelistViewModel { + private model: PageWhitelistModel; + + constructor(dialogHandler: WhitelistDialogHandler, closeWebpageCallback?: () => void) { + this.model = new PageWhitelistModel(dialogHandler, closeWebpageCallback); + } + + /** + * Updates the whitelist URLs + */ + setWhitelistUrls(urls: string[]): void { + this.model.setWhitelistUrls(urls); + } + + /** + * Reset whitelist session state for a fresh load + */ + resetSession(): void { + this.model.resetSession(); + } + + /** + * Handles load intercept logic + */ + onLoadIntercept(event: OnLoadInterceptEvent): boolean { + return this.model.processLoadIntercept(event); + } +} diff --git a/entry/src/main/ets/Interceptors/RedirectRequestInterceptor/model/RedirectRequestModel.ets b/entry/src/main/ets/Interceptors/RedirectRequestInterceptor/model/RedirectRequestModel.ets new file mode 100644 index 0000000000000000000000000000000000000000..4c94d8a0e2dc8036fd9f3ceb3aff1aedd6708e20 --- /dev/null +++ b/entry/src/main/ets/Interceptors/RedirectRequestInterceptor/model/RedirectRequestModel.ets @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2025 Huawei Device Co., Ltd. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { webview } from '@kit.ArkWeb'; +import { BusinessError } from '@kit.BasicServicesKit'; +import Logger from '../../../common/utils/Logger'; + +const TAG = '[RedirectRequestModel]'; + +/** + * Model for RedirectRequest scene + */ +export class RedirectRequestModel { + private controller: webview.WebviewController; + private redirectUrl: string = ''; + private allowAllForCurrentLoad: boolean = false; + + constructor(controller: webview.WebviewController) { + this.controller = controller; + } + + /** + * Reset session-level allow flags when starting a new load + */ + resetSession(): void { + this.allowAllForCurrentLoad = false; + } + + /** + * Updates the redirect target URL + */ + setRedirectUrl(redirectUrl: string): void { + this.redirectUrl = redirectUrl; + } + + /** + * Normalizes the URL + */ + private normalizeUrl(url: string): string { + return url + .replace(/^(?:[a-zA-Z]+:)?\/\//, '') + .replace(/\/+$/, '') + .trim(); + } + + /** + * Checks if the URL needs to be intercepted and redirected + */ + shouldInterceptUrl(requestUrl: string): boolean { + if (!this.redirectUrl) { + return false; + } + const normalizedRequest = this.normalizeUrl(requestUrl); + const normalizedRedirect = this.normalizeUrl(this.redirectUrl); + const isRedirectTarget = normalizedRequest === normalizedRedirect; + return !isRedirectTarget; + } + + /** + * Performs the redirect operation + */ + performRedirect(): boolean { + try { + this.controller.loadUrl(this.redirectUrl); + this.allowAllForCurrentLoad = true; + Logger.info(TAG, `Redirected to ${this.redirectUrl}`); + return true; + } catch (error) { + let err = error as BusinessError; + Logger.error(TAG, `Redirect to ${this.redirectUrl} failed, errCode: ${err.code}, errMessage: ${err.message}.`); + return false; + } + } + + /** + * Processes the load intercept event + * Returns true if loading should be blocked (redirect performed), false to allow + */ + processLoadIntercept(event: OnLoadInterceptEvent): boolean { + const requestUrl = event.data.getRequestUrl(); + + if (this.allowAllForCurrentLoad) { + return false; + } + + if (this.shouldInterceptUrl(requestUrl)) { + // Perform redirect + const redirected = this.performRedirect(); + return redirected; // Block original URL if redirect successful + } + + return false; // Allow loading + } +} diff --git a/entry/src/main/ets/Interceptors/RedirectRequestInterceptor/view/RedirectRequestView.ets b/entry/src/main/ets/Interceptors/RedirectRequestInterceptor/view/RedirectRequestView.ets new file mode 100644 index 0000000000000000000000000000000000000000..80b16d5c11d667c6e3c5d329d454289266e839b7 --- /dev/null +++ b/entry/src/main/ets/Interceptors/RedirectRequestInterceptor/view/RedirectRequestView.ets @@ -0,0 +1,143 @@ +/* + * Copyright (c) 2025 Huawei Device Co., Ltd. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { webview } from '@kit.ArkWeb'; +import { BusinessError } from '@kit.BasicServicesKit'; +import { CommonConstants } from '../../../common/CommonConstants'; +import { UrlInputComponent } from '../../../component/UrlInputComponent'; +import { RedirectRequestViewModel } from '../viewmodel/RedirectRequestViewModel'; +import Logger from '../../../common/utils/Logger'; + +const TAG = '[RedirectRequest]'; + +@Builder +export function RedirectRequestBuilder() { + RedirectRequest() +} + +/** + * Pages that redirect requests through onLoadIntercept. + */ +@Component +struct RedirectRequest { + @Consume('NavPathStack') pageStack: NavPathStack; + @State originUrl: ResourceStr = CommonConstants.DEFAULT_REQUEST_URL; + @State redirectUrl: ResourceStr = CommonConstants.EXAMPLE_URL; + @State isLoading: boolean = false; + controller: webview.WebviewController = new webview.WebviewController(); + viewModel?: RedirectRequestViewModel; + + aboutToAppear(): void { + // Initialize redirect request interceptor + this.viewModel = new RedirectRequestViewModel(this.controller); + } + + build() { + NavDestination() { + Scroll() { + Column() { + UrlInputComponent({ + url: this.originUrl, + urlTitle: $r('app.string.origin_url'), + inputAlert: $r('app.string.url_input_alert') + }) + + UrlInputComponent({ + url: this.redirectUrl, + urlTitle: $r('app.string.redirect_url'), + inputAlert: $r('app.string.url_input_alert') + }) + + Blank() + .layoutWeight(1) + + Button($r('app.string.load_web_page')) + .height($r('app.float.load_web_button_height')) + .width(CommonConstants.FULL_PERCENT) + .margin($r('app.float.load_web_button_margin')) + .constraintSize({ maxWidth: CommonConstants.FULL_PERCENT }) + .onClick(() => { + if (this.redirectUrl.toString().length === 0 || this.originUrl.toString().length === 0) { + try { + this.getUIContext() + .getPromptAction() + .showToast({ + message: $r('app.string.url_can_not_be_null'), + duration: CommonConstants.TOAST_DURATION + }); + return; + } catch (error) { + const err = error as BusinessError; + Logger.error(TAG, `Failed to show toast, errCode: ${err.code}, errMessage: ${err.message}.`); + } + } + this.viewModel?.resetSession(); + this.isLoading = true; + }) + } + .padding({ top: $r('app.float.url_input_top_padding') }) + .layoutWeight(1) + .width(CommonConstants.FULL_PERCENT) + .bindSheet($$this.isLoading, this.WebLoading(), { + height: CommonConstants.FULL_PERCENT, + backgroundColor: Color.White, + title: { + title: CommonConstants.WEB_PAGE_NAME + } + }) + } + } + .title($r('app.string.request_redirect')) + .backgroundColor($r('app.color.home_page_background')) + .onBackPressed(() => { + // If there is a previous web page, go back to the previous page; otherwise, close the web page. + if (!this.isLoading) { + return false; + } + let isLastPage = false; + try { + isLastPage = this.controller.accessStep(-1); + if (isLastPage) { + this.controller.backward(); + return true; + } + return false; + } catch (error) { + let err = error as BusinessError; + Logger.error(TAG, `Get the accessStep failed, errCode: ${err.code}, errMessage: ${err.message}.`); + return false; + } + }) + } + + /** + * Builds the sheet that injects redirect logic via onLoadIntercept. + */ + @Builder + WebLoading() { + Web({ src: this.originUrl, controller: this.controller }) + .onLoadIntercept((event) => { + // Update redirect URLs before intercepting + this.viewModel?.updateRedirectTargets(this.redirectUrl.toString()); + // Use load intercept + return this.viewModel?.onLoadIntercept(event) ?? false; + }) + .javaScriptAccess(true) + .fileAccess(true) + .domStorageAccess(true) + .imageAccess(true) + .backgroundColor($r('sys.color.background_secondary')) + } +} \ No newline at end of file diff --git a/entry/src/main/ets/Interceptors/RedirectRequestInterceptor/viewmodel/RedirectRequestViewModel.ets b/entry/src/main/ets/Interceptors/RedirectRequestInterceptor/viewmodel/RedirectRequestViewModel.ets new file mode 100644 index 0000000000000000000000000000000000000000..81cbbb08a33980c2da4cc541db217e29c80894dc --- /dev/null +++ b/entry/src/main/ets/Interceptors/RedirectRequestInterceptor/viewmodel/RedirectRequestViewModel.ets @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2025 Huawei Device Co., Ltd. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { webview } from '@kit.ArkWeb'; +import { RedirectRequestModel } from '../model/RedirectRequestModel'; + +/** + * ViewModel for RedirectRequest scene + */ +export class RedirectRequestViewModel { + private model: RedirectRequestModel; + + constructor(controller: webview.WebviewController) { + this.model = new RedirectRequestModel(controller); + } + + /** + * Updates the redirect target URL + */ + updateRedirectTargets(redirectUrl: string): void { + this.model.setRedirectUrl(redirectUrl); + } + + /** + * Reset redirect request session state for a fresh load + */ + resetSession(): void { + this.model.resetSession(); + } + + /** + * Handles load intercept logic + */ + onLoadIntercept(event: OnLoadInterceptEvent): boolean { + return this.model.processLoadIntercept(event); + } +} diff --git a/entry/src/main/ets/common/CommonConstants.ets b/entry/src/main/ets/common/CommonConstants.ets new file mode 100644 index 0000000000000000000000000000000000000000..431f41f4935069db861613ea16efd373253fd385 --- /dev/null +++ b/entry/src/main/ets/common/CommonConstants.ets @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2025 Huawei Device Co., Ltd. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Subpage Name + */ +export class PageNames { + static readonly REQUEST_REDIRECT: string = 'RedirectRequest'; + + static readonly CONFIGURE_WHITELIST: string = 'PageWhitelist'; + + static readonly LOCAL_SOURCE_REPLACE: string = 'LocalResource'; + + static readonly CUSTOM_RESOURCE_LOADING: string = 'CustomLoadingStrategy'; + + static readonly COMMON_REQUEST_HEADER: string = 'CommonHeader'; +} + +export interface RouteItem { + index: number; + title: ResourceStr; + name: string; +} + +export interface RouteGroup { + title: ResourceStr; + items: RouteItem[]; +} + +export class CommonConstants { + // Route data list + static readonly ROUTE_GROUPS: RouteGroup[] = [ + { + title: $r('app.string.based_on_onLoadIntercept'), + items: [ + { index: 0, title: $r('app.string.request_redirect'), name: PageNames.REQUEST_REDIRECT }, + { index: 1, title: $r('app.string.configure_whitelist'), name: PageNames.CONFIGURE_WHITELIST } + ] + }, + { + title: $r('app.string.based_on_onInterceptRequest'), + items: [ + { index: 2, title: $r('app.string.local_resource_replacement'), name: PageNames.LOCAL_SOURCE_REPLACE }, + { index: 3, title: $r('app.string.custom_resource_loading_strategy'), name: PageNames.CUSTOM_RESOURCE_LOADING } + ] + }, + { + title: $r('app.string.based_on_WebSchemeHandler'), + items: [ + { index: 4, title: $r('app.string.configure_common_request_header'), name: PageNames.COMMON_REQUEST_HEADER } + ] + } + ] + + static readonly FULL_PERCENT: string = '100%'; + + static readonly EIGHTY_PERCENT: string = '80%'; + + static readonly FIFTY_PERCENT: string = '50%'; + + static readonly SEVENTY_FIVE_PERCENT: string = '75%'; + + static readonly TOAST_DURATION: number = 3000; + + static readonly EXAMPLE_URL: string = 'https://www.example.com/'; + + static readonly DEFAULT_REQUEST_URL: string = 'https://developer.huawei.com/consumer/cn/'; + + static readonly IMAGE_REQUEST_URL: string = 'https://developer.huawei.com/allianceCmsResource/resource/HUAWEI_Developer_VUE/images/0603public/APPICON.png'; + + // Default to localhost, user should update this to their PC's local network IP + // Get the IP from the server console output when starting the server + static readonly COMMON_HEADER_REQUEST_URL: string = 'http://*.*.*.*:8081/api/headers'; + + static readonly WEB_PAGE_NAME: string = 'Web Page'; + + static readonly IMAGE_FORMAT_LIST: Array = ['.png', '.jpg', '.jpeg']; + + static readonly IMAGE_PLACEHOLDER: string = 'mountain.png'; + + static readonly IMAGE_NO_WLAN: string = 'no_wlan.jpg'; +} \ No newline at end of file diff --git a/entry/src/main/ets/common/utils/Logger.ets b/entry/src/main/ets/common/utils/Logger.ets new file mode 100644 index 0000000000000000000000000000000000000000..9e06287ba1709d4c7263f98116c03b59056a1c45 --- /dev/null +++ b/entry/src/main/ets/common/utils/Logger.ets @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2025 Huawei Device Co., Ltd. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { hilog } from '@kit.PerformanceAnalysisKit'; + +class Logger { + private domain: number; + private prefix: string; + private format: string = '%{public}s, %{public}s'; + + /** + * constructor. + * + * @param prefix Identifies the log tag. + * @param domain Indicates the service domain, which is a hexadecimal integer ranging from 0x0 to 0xFFFFF + * @param args Indicates the log parameters. + */ + constructor(prefix: string = '', domain: number = 0xFF00) { + this.prefix = prefix; + this.domain = domain; + } + + debug(...args: string[]): void { + hilog.debug(this.domain, this.prefix, this.format, args); + } + + info(...args: string[]): void { + hilog.info(this.domain, this.prefix, this.format, args); + } + + warn(...args: string[]): void { + hilog.warn(this.domain, this.prefix, this.format, args); + } + + error(...args: string[]): void { + hilog.error(this.domain, this.prefix, this.format, args); + } +} + +export default new Logger('WebInterceptor'); \ No newline at end of file diff --git a/entry/src/main/ets/common/utils/RcpRequestForwarder.ets b/entry/src/main/ets/common/utils/RcpRequestForwarder.ets new file mode 100644 index 0000000000000000000000000000000000000000..2221995e963a9a769da5a6f1c6c7638c866fa561 --- /dev/null +++ b/entry/src/main/ets/common/utils/RcpRequestForwarder.ets @@ -0,0 +1,255 @@ +/* + * Copyright (c) 2025 Huawei Device Co., Ltd. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { rcp } from '@kit.RemoteCommunicationKit'; +import { webview, WebNetErrorList } from '@kit.ArkWeb'; +import { BusinessError } from '@kit.BasicServicesKit'; +import Logger from './Logger'; + +const TAG = '[RcpRequestForwarder]'; + +export interface RcpForwarderConfig { + commonHeaderName: string; + commonHeaderValue: string; +} + +/** + * Utility class for forwarding requests via RCP session + */ +export class RcpRequestForwarder { + private config: RcpForwarderConfig; + private session?: rcp.Session; + + constructor(config: RcpForwarderConfig) { + this.config = config; + } + + /** + * Forward intercepted request to target server using RCP session + */ + forwardRequest(request: webview.WebSchemeHandlerRequest, resourceHandler: webview.WebResourceHandler): void { + try { + const originalUrl = request.getRequestUrl(); + const method = request.getRequestMethod(); + Logger.info(TAG, `Forward request: ${originalUrl}, method: ${method}`); + + // Collect original headers + const headers = this.collectHeaders(request); + + // Add common header + headers[this.config.commonHeaderName] = this.config.commonHeaderValue; + Logger.info(TAG, `Added header ${this.config.commonHeaderName}: ${this.config.commonHeaderValue}`); + + if (method === 'GET' || method === 'HEAD') { + this.forwardGetRequest(originalUrl, headers, resourceHandler); + } else if (method === 'POST' || method === 'PUT' || method === 'PATCH' || method === 'DELETE') { + this.forwardMutationRequest(originalUrl, method, headers, resourceHandler); + } else { + Logger.error(TAG, `Unsupported request method: ${method}`); + } + } catch (error) { + const err = error as BusinessError; + Logger.error(TAG, `Failed to forwad request, errCode: ${err.code}, errMessage: ${err.message}`); + } + } + + /** + * Collects headers from the incoming web request. + */ + private collectHeaders(request: webview.WebSchemeHandlerRequest): Record { + const result: Record = {}; + const headers = request.getHeader(); + for (let i = 0; i < headers.length; i++) { + const headerName = headers[i].headerKey; + const headerValue = headers[i].headerValue; + if (headerName && headerValue) { + result[headerName] = headerValue; + } + } + return result; + } + + /** + * Creates an RCP session for the next outbound request. + */ + private createSession(headers: Record): void { + try { + // Create RCP session + const sessionConfig: rcp.SessionConfiguration = { + headers: headers + }; + + this.session = rcp.createSession(sessionConfig); + } catch (error) { + const err = error as BusinessError; + Logger.error(TAG, `Failed to create rcp session, errCode: ${err.code}, errMessage: ${err.message}`); + } + } + + /** + * Sends GET or HEAD requests via the RCP session. + */ + private forwardGetRequest( + targetUrl: string, + headers: Record, + resourceHandler: webview.WebResourceHandler, + ): void { + try { + Logger.info(TAG, `Forward GET: ${targetUrl}`); + this.createSession(headers); + + this.session?.get(targetUrl).then((response: rcp.Response) => { + Logger.info(TAG, `GET success: ${targetUrl}`); + this.handleResponse(response, resourceHandler); + this.session?.close(); + }).catch((error: BusinessError) => { + Logger.error(TAG, `Failed to get, errCode: ${error.code}, errMessage: ${error.message}`); + this.session?.close(); + }); + } catch (error) { + const err = error as BusinessError; + Logger.error(TAG, `GET exception, errCode: ${err.code}, errMessage: ${err.message}`); + } + } + + /** + * Sends mutation requests and forwards the response back to the web resource. + */ + private forwardMutationRequest( + targetUrl: string, + method: string, + headers: Record, + resourceHandler: webview.WebResourceHandler, + ): void { + try { + Logger.info(TAG, `Forward ${method}: ${targetUrl}`); + this.createSession(headers); + + switch (method) { + case 'POST': + this.session?.post(targetUrl).then((response: rcp.Response) => { + Logger.info(TAG, `${method} success: ${targetUrl}`); + this.handleResponse(response, resourceHandler); + this.session?.close(); + }).catch((error: BusinessError) => { + Logger.error(TAG, `${method} failed, errCode: ${error.code}, errMessage: ${error.message}`); + this.session?.close(); + }); + break; + case 'PUT': + this.session?.put(targetUrl).then((response: rcp.Response) => { + Logger.info(TAG, `${method} success: ${targetUrl}`); + this.handleResponse(response, resourceHandler); + this.session?.close(); + }).catch((error: BusinessError) => { + Logger.error(TAG, `${method} failed, errCode: ${error.code}, errMessage: ${error.message}`); + this.session?.close(); + }); + break; + case 'PATCH': + let request = new rcp.Request(targetUrl, 'PATCH', headers); + this.session?.fetch(request).then((response: rcp.Response) => { + Logger.info(TAG, `${method} success: ${targetUrl}`); + this.handleResponse(response, resourceHandler); + this.session?.close(); + }).catch((error: BusinessError) => { + Logger.error(TAG, `${method} failed, errCode: ${error.code}, errMessage: ${error.message}`); + this.session?.close(); + }); + break; + case 'DELETE': + this.session?.delete(targetUrl).then((response: rcp.Response) => { + Logger.info(TAG, `${method} success: ${targetUrl}`); + this.handleResponse(response, resourceHandler); + this.session?.close(); + }).catch((error: BusinessError) => { + Logger.error(TAG, `${method} failed, errCode: ${error.code}, errMessage: ${error.message}`); + this.session?.close(); + }); + break; + default: + this.session?.close(); + return; + } + } catch (error) { + const err = error as BusinessError; + Logger.error(TAG, `${method} exception, errCode: ${err.code}, errMessage: ${err.message}`); + } + } + + /** + * Maps the RCP response to a WebSchemeHandlerResponse. + */ + private handleResponse( + response: rcp.Response, + resourceHandler: webview.WebResourceHandler, + ): void { + try { + const webResponse = new webview.WebSchemeHandlerResponse(); + webResponse.setStatus(response.statusCode || 200); + webResponse.setStatusText('OK'); + + let mimeType = 'application/json'; + let encoding = 'utf-8'; + + if (response.headers) { + const contentType = response.headers['content-type'] || response.headers['Content-Type']; + if (contentType) { + const contentTypeStr = Array.isArray(contentType) ? contentType[0] : String(contentType); + Logger.info(TAG, `Response Content-Type: ${contentTypeStr}`); + + if (contentTypeStr.toLowerCase().includes('text/html')) { + mimeType = 'text/html'; + } else if (contentTypeStr.toLowerCase().includes('application/json')) { + mimeType = 'application/json'; + } + + const charsetMatch = contentTypeStr.match(/charset=([^;]+)/i); + if (charsetMatch && charsetMatch[1]) { + encoding = charsetMatch[1].trim(); + } + } + } + + webResponse.setMimeType(mimeType); + webResponse.setEncoding(encoding); + webResponse.setNetErrorCode(WebNetErrorList.NET_OK); + + // Set CORS headers + webResponse.setHeaderByName('Access-Control-Allow-Origin', '*', true); + webResponse.setHeaderByName('Access-Control-Allow-Credentials', 'true', true); + webResponse.setHeaderByName('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, PATCH, OPTIONS', true); + webResponse.setHeaderByName('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Custom-Header', true); + + if (response.headers) { + const headerKeys = Object.keys(response.headers); + for (let i = 0; i < headerKeys.length; i++) { + const key = headerKeys[i]; + const value = response.headers[key]; + if (value && key.toLowerCase() !== 'content-type') { + webResponse.setHeaderByName(key, Array.isArray(value) ? value.join(', ') : String(value), true); + } + } + } + + resourceHandler.didReceiveResponse(webResponse); + resourceHandler.didReceiveResponseBody(response.body); + resourceHandler.didFinish(); + } catch (error) { + const err = error as BusinessError; + Logger.error(TAG, `Handle response failed, errCode: ${err.code}, errMessage: ${err.message}`); + } + } +} \ No newline at end of file diff --git a/entry/src/main/ets/component/NavAreaComponent.ets b/entry/src/main/ets/component/NavAreaComponent.ets new file mode 100644 index 0000000000000000000000000000000000000000..fc9b23909f93230f0bcac2f7a43a240f00dfb991 --- /dev/null +++ b/entry/src/main/ets/component/NavAreaComponent.ets @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2025 Huawei Device Co., Ltd. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { SubHeader, TextModifier } from '@kit.ArkUI'; +import { CommonConstants, RouteGroup, RouteItem } from '../common/CommonConstants'; + +/** + * Title navigation for jumping to the next-level page + */ +@Component +export struct NavArea { + @Consume('NavPathStack') pageStack: NavPathStack; + routeGroup?: RouteGroup; + primaryModifier: TextModifier = new TextModifier().fontColor(Color.Gray) + .fontSize($r('app.float.sub_header_font_size')) + .fontWeight(FontWeight.Medium); + + build() { + Column() { + SubHeader({ + primaryTitle: this.routeGroup?.title, + primaryTitleModifier: this.primaryModifier + }) + + List() { + ForEach(this.routeGroup?.items, (itemContent: RouteItem) => { + ListItem() { + Row() { + Text(itemContent.title) + .fontSize($r('app.float.route_title_font_size')) + .fontWeight(FontWeight.Medium) + .height($r('app.float.route_title_line_height')) + + Image($r('app.media.ic_arrow')) + .width($r('app.float.arrow_img_size')) + .height($r('app.float.arrow_img_size')) + } + .width(CommonConstants.FULL_PERCENT) + .justifyContent(FlexAlign.SpaceBetween) + .padding({ + left: $r('app.float.route_title_padding'), + right: $r('app.float.route_title_padding') + }) + } + .onClick(() => { + this.pageStack.replacePath({ name: itemContent.name }); + }) + }, (itemContent: RouteItem) => JSON.stringify(itemContent.index)) + } + .divider({ + strokeWidth: $r('app.float.divider_width'), + color: $r('app.color.divider_color'), + startMargin: $r('app.float.divider_margin'), + endMargin: $r('app.float.divider_margin') + }) + .borderRadius($r('app.float.route_list_border_radius')) + .margin({ + left: $r('app.float.route_list_margin'), + right: $r('app.float.route_list_margin') + }) + .backgroundColor(Color.White) + } + } +} \ No newline at end of file diff --git a/entry/src/main/ets/component/UrlInputComponent.ets b/entry/src/main/ets/component/UrlInputComponent.ets new file mode 100644 index 0000000000000000000000000000000000000000..7ead6541bd03f662c07e3dbe870ab58fd09e6025 --- /dev/null +++ b/entry/src/main/ets/component/UrlInputComponent.ets @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2025 Huawei Device Co., Ltd. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { CommonConstants } from '../common/CommonConstants'; + +/** + * Component for entering URL information + */ +@Component +export struct UrlInputComponent { + @Link url: ResourceStr; + @Prop urlTitle: ResourceStr; + @Prop inputAlert: ResourceStr; + + build() { + Column({ space: $r('app.float.url_input_space') }) { + Text(this.urlTitle) + .fontSize($r('app.float.url_title_font_size')) + .fontColor(Color.Grey) + .fontWeight(FontWeight.Medium) + .textAlign(TextAlign.Start) + .width(CommonConstants.FULL_PERCENT) + TextInput({ text: this.url, placeholder: this.inputAlert }) + .type(InputType.URL) + .width(CommonConstants.FULL_PERCENT) + .backgroundColor(Color.White) + .fontSize($r('app.float.url_input_font_size')) + .fontWeight(FontWeight.Regular) + .onWillInsert((info: InsertValue) => !(info.insertOffset === 0 && info.insertValue === ' ')) + .onChange((value) => { + this.url = value; + }) + .borderRadius($r('app.float.url_input_border_radius')) + } + .margin({ + bottom: $r('app.float.url_input_margin') + }) + .padding({ + left: $r('app.float.url_input_padding'), + right: $r('app.float.url_input_padding') + }) + } +} \ No newline at end of file diff --git a/entry/src/main/ets/component/WhitelistDialog.ets b/entry/src/main/ets/component/WhitelistDialog.ets new file mode 100644 index 0000000000000000000000000000000000000000..7e2291ac2d5821a48384467319b4ac44077e2679 --- /dev/null +++ b/entry/src/main/ets/component/WhitelistDialog.ets @@ -0,0 +1,176 @@ +/* + * Copyright (c) 2025 Huawei Device Co., Ltd. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { BusinessError } from '@kit.BasicServicesKit'; +import { common, Want } from '@kit.AbilityKit'; +import { CommonConstants } from '../common/CommonConstants'; +import Logger from '../common/utils/Logger'; + +const TAG = '[WhitelistDialog]'; + +/** + * Whitelist dialog configuration + */ +export class WhitelistDialogConfig { + title: ResourceStr = $r('app.string.open_in_browser_title'); + message: ResourceStr = $r('app.string.open_in_browser_message'); + cancelText: ResourceStr = $r('app.string.cancel'); + okText: ResourceStr = $r('app.string.ok'); + onCancel?: () => void; + onOk?: (url: string) => void; + onDismiss?: () => void; +} + +/** + * Whitelist dialog handler + * Handles the dialog display and user interactions + */ +export class WhitelistDialogHandler { + private uiContext?: UIContext; + private config?: WhitelistDialogConfig; + private targetUrl: string = ''; + + /** + * Set UI context for dialog display + */ + setUIContext(uiContext: UIContext): void { + this.uiContext = uiContext; + } + + /** + * Show the whitelist dialog + * @param url The URL that triggered the dialog + * @param config Dialog configuration + */ + showDialog(url: string, config: WhitelistDialogConfig): void { + this.targetUrl = url; + this.config = config; + + if (!this.uiContext) { + Logger.error(TAG, 'UIContext is not set, cannot show dialog.'); + return; + } + + try { + this.uiContext.showAlertDialog({ + title: config.title, + message: config.message, + autoCancel: true, + alignment: DialogAlignment.Center, + offset: { dx: 0, dy: -20 }, + gridCount: 3, + width: 328, + primaryButton: { + value: config.cancelText, + action: () => { + this.handleCancel(); + } + }, + secondaryButton: { + value: config.okText, + action: () => { + this.handleOk(); + } + }, + onWillDismiss: () => { + this.handleDismiss(); + } + }); + } catch (error) { + const err = error as BusinessError; + Logger.error(TAG, `Show dialog failed, errCode: ${err.code}, errMessage: ${err.message}.`); + } + } + + /** + * Handle cancel button click + */ + private handleCancel(): void { + if (this.config?.onCancel) { + this.config.onCancel(); + } + } + + /** + * Handle OK button click + */ + private handleOk(): void { + this.openInBrowser(this.targetUrl); + } + + /** + * Handle dialog dismiss + */ + private handleDismiss(): void { + if (this.config?.onDismiss) { + this.config.onDismiss(); + } + } + + /** + * Open URL in external browser + */ + private openInBrowser(url: string): void { + if (!url.startsWith('http://') && !url.startsWith('https://')) { + this.showUrlErrorToast(); + return; + } + + const want: Want = { + uri: url, + action: 'ohos.want.action.viewData', + entities: ['entity.system.browsable'], + parameters: { + 'ohos.ability.params.showDefaultPicker': true + } + }; + + if (!this.uiContext) { + Logger.error(TAG, 'UIContext is not set, cannot open browser.'); + return; + } + + const context: common.UIAbilityContext = this.uiContext.getHostContext()! as common.UIAbilityContext; + context.startAbility(want) + .then(() => { + if (this.config?.onOk) { + this.config?.onOk(this.targetUrl) + } + Logger.info(TAG, 'Redirected to open in the browser success!'); + }) + .catch((err: BusinessError) => { + Logger.error(TAG, `Redirect to open in the browser failed, errCode: ${err.code}, errMessage: ${err.message}.`); + }); + } + + /** + * Show URL error toast + */ + private showUrlErrorToast(): void { + if (!this.uiContext) { + return; + } + + try { + this.uiContext + .getPromptAction() + .showToast({ message: $r('app.string.check_the_url'), duration: CommonConstants.TOAST_DURATION }); + } catch (error) { + const err = error as BusinessError; + Logger.error(TAG, `Show toast failed, errCode: ${err.code}, errMessage: ${err.message}.`); + } + } +} + diff --git a/entry/src/main/ets/entryability/EntryAbility.ets b/entry/src/main/ets/entryability/EntryAbility.ets new file mode 100644 index 0000000000000000000000000000000000000000..62f53acbc95e5be7985f8fb1fe2db591cea260bd --- /dev/null +++ b/entry/src/main/ets/entryability/EntryAbility.ets @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2025 Huawei Device Co., Ltd. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { AbilityConstant, ConfigurationConstant, UIAbility, Want } from '@kit.AbilityKit'; +import { hilog } from '@kit.PerformanceAnalysisKit'; +import { window } from '@kit.ArkUI'; + +const DOMAIN = 0x0000; + +export default class EntryAbility extends UIAbility { + onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void { + try { + this.context.getApplicationContext().setColorMode(ConfigurationConstant.ColorMode.COLOR_MODE_NOT_SET); + } catch (err) { + hilog.error(DOMAIN, 'testTag', 'Failed to set colorMode. Cause: %{public}s', JSON.stringify(err)); + } + hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onCreate'); + } + + onDestroy(): void { + hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onDestroy'); + } + + onWindowStageCreate(windowStage: window.WindowStage): void { + // Main window is created, set main page for this ability + hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onWindowStageCreate'); + + windowStage.loadContent('pages/Index', (err) => { + if (err.code) { + hilog.error(DOMAIN, 'testTag', 'Failed to load the content. Cause: %{public}s', JSON.stringify(err)); + return; + } + hilog.info(DOMAIN, 'testTag', 'Succeeded in loading the content.'); + }); + } + + onWindowStageDestroy(): void { + // Main window is destroyed, release UI related resources + hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onWindowStageDestroy'); + } + + onForeground(): void { + // Ability has brought to foreground + hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onForeground'); + } + + onBackground(): void { + // Ability has back to background + hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onBackground'); + } +} \ No newline at end of file diff --git a/entry/src/main/ets/pages/Index.ets b/entry/src/main/ets/pages/Index.ets new file mode 100644 index 0000000000000000000000000000000000000000..c3b9d98838b2dd4fba6af1e92c7a69fb66937eba --- /dev/null +++ b/entry/src/main/ets/pages/Index.ets @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2025 Huawei Device Co., Ltd. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { CommonConstants, RouteGroup } from '../common/CommonConstants'; +import { NavArea } from '../component/NavAreaComponent'; + +@Entry +@Component +struct Index { + @Provide('NavPathStack') pageStack: NavPathStack = new NavPathStack(); + private routes: RouteGroup[] = CommonConstants.ROUTE_GROUPS; + + build() { + Navigation(this.pageStack) { + Scroll() { + Column() { + ForEach(this.routes, (item: RouteGroup) => { + NavArea({ routeGroup: item }) + }, (item: RouteGroup, index: number) => JSON.stringify(item) + index) + } + .layoutWeight(1) + .width(CommonConstants.FULL_PERCENT) + } + .padding({ bottom: '20vp' }) + } + .mode(NavigationMode.Auto) + .title($r('app.string.home_page_title')) + .backgroundColor($r('app.color.home_page_background')) + .navBarWidth('50%') + } +} \ No newline at end of file diff --git a/entry/src/main/module.json5 b/entry/src/main/module.json5 new file mode 100644 index 0000000000000000000000000000000000000000..e8dc86cbca1e58bc08b7c81c857a0617528c591f --- /dev/null +++ b/entry/src/main/module.json5 @@ -0,0 +1,47 @@ +{ + "module": { + "name": "entry", + "type": "entry", + "description": "$string:module_desc", + "mainElement": "EntryAbility", + "deviceTypes": [ + "phone" + ], + "deliveryWithInstall": true, + "installationFree": false, + "pages": "$profile:main_pages", + "routerMap": "$profile:route_map", + "abilities": [ + { + "name": "EntryAbility", + "srcEntry": "./ets/entryability/EntryAbility.ets", + "description": "$string:EntryAbility_desc", + "icon": "$media:layered_image", + "label": "$string:EntryAbility_label", + "startWindowIcon": "$media:startIcon", + "startWindowBackground": "$color:start_window_background", + "exported": true, + "skills": [ + { + "entities": [ + "entity.system.home" + ], + "actions": [ + "ohos.want.action.home" + ] + } + ] + } + ], + "requestPermissions": [ + { + "name": "ohos.permission.INTERNET", + "reason": "$string:internet_permission" + }, + { + "name": "ohos.permission.GET_NETWORK_INFO", + "reason": "$string:network_info_permission" + } + ] + } +} \ No newline at end of file diff --git a/entry/src/main/resources/base/element/color.json b/entry/src/main/resources/base/element/color.json new file mode 100644 index 0000000000000000000000000000000000000000..d98b7c13adc00070c7323515959c6acf17d3cad6 --- /dev/null +++ b/entry/src/main/resources/base/element/color.json @@ -0,0 +1,20 @@ +{ + "color": [ + { + "name": "start_window_background", + "value": "#FFFFFF" + }, + { + "name": "home_page_background", + "value": "#F1F3F5" + }, + { + "name": "divider_color", + "value": "#0D000000" + }, + { + "name": "explain_font_color", + "value": "#99000000" + } + ] +} \ No newline at end of file diff --git a/entry/src/main/resources/base/element/float.json b/entry/src/main/resources/base/element/float.json new file mode 100644 index 0000000000000000000000000000000000000000..c47518a3d6d01abd401854a33cabb70f5f1af220 --- /dev/null +++ b/entry/src/main/resources/base/element/float.json @@ -0,0 +1,84 @@ +{ + "float": [ + { + "name": "divider_width", + "value": "0.5vp" + }, + { + "name": "divider_margin", + "value": "12vp" + }, + { + "name": "sub_header_font_size", + "value": "14fp" + }, + { + "name": "route_title_font_size", + "value": "16fp" + }, + { + "name": "route_title_line_height", + "value": "48fp" + }, + { + "name": "route_title_padding", + "value": "12vp" + }, + { + "name": "route_list_border_radius", + "value": "16vp" + }, + { + "name": "route_list_margin", + "value": "12vp" + }, + { + "name": "arrow_img_size", + "value": "24vp" + }, + { + "name": "url_input_space", + "value": "12vp" + }, + { + "name": "url_title_font_size", + "value": "16fp" + }, + { + "name": "url_input_font_size", + "value": "16fp" + }, + { + "name": "url_input_border_radius", + "value": "16vp" + }, + { + "name": "url_input_margin", + "value": "16vp" + }, + { + "name": "url_input_padding", + "value": "16vp" + }, + { + "name": "url_input_top_padding", + "value": "18vp" + }, + { + "name": "load_web_button_margin", + "value": "16vp" + }, + { + "name": "load_web_button_height", + "value": "40vp" + }, + { + "name": "url_input_explain_font_size", + "value": "12fp" + }, + { + "name": "url_input_explain_margin", + "value": "24vp" + } + ] +} diff --git a/entry/src/main/resources/base/element/string.json b/entry/src/main/resources/base/element/string.json new file mode 100644 index 0000000000000000000000000000000000000000..633d4ec8531b30ebb30b2d88f45e414eb8a4317c --- /dev/null +++ b/entry/src/main/resources/base/element/string.json @@ -0,0 +1,116 @@ +{ + "string": [ + { + "name": "module_desc", + "value": "module description" + }, + { + "name": "EntryAbility_desc", + "value": "description" + }, + { + "name": "EntryAbility_label", + "value": "WebInterceptor" + }, + { + "name": "home_page_title", + "value": "Web Interception" + }, + { + "name": "request_redirect", + "value": "Request redirection" + }, + { + "name": "configure_whitelist", + "value": "Configure whitelist" + }, + { + "name": "local_resource_replacement", + "value": "Local resource replacement" + }, + { + "name": "custom_resource_loading_strategy", + "value": "Custom resource loading strategy" + }, + { + "name": "configure_common_request_header", + "value": "Configure common request header" + }, + { + "name": "based_on_onLoadIntercept", + "value": "Request interception based on onLoadIntercept" + }, + { + "name": "based_on_onInterceptRequest", + "value": "Request interception based on onInterceptRequest" + }, + { + "name": "based_on_WebSchemeHandler", + "value": "Request interception based on WebSchemeHandler" + }, + { + "name": "url_input_alert", + "value": "Please input url..." + }, + { + "name": "origin_url", + "value": "Origin url" + }, + { + "name": "redirect_url", + "value": "Redirect url" + }, + { + "name": "load_web_page", + "value": "Load web page" + }, + { + "name": "white_list_url", + "value": "Whitelist url" + }, + { + "name": "loading_url", + "value": "Loading url" + }, + { + "name": "white_list_url_explain", + "value": "URLs configured in the whitelist will be opened through the in-app ArkWeb component, while other URLs will be opened in the browser. For multiple whitelist URLs, please separate them with ','." + }, + { + "name": "open_in_browser_title", + "value": "Allow opening the link in a browser?" + }, + { + "name": "open_in_browser_message", + "value": "The URL being loaded is not in the whitelist. Do you want to open it in the browser?" + }, + { + "name": "cancel", + "value": "Cancel" + }, + { + "name": "ok", + "value": "OK" + }, + { + "name": "check_the_url", + "value": "Please check if the loaded URL uses the http or https protocol!" + }, + { + "name": "request_url", + "value": "Request url" + }, + { + "name": "local_resource_replace_explain", + "value": "URLs starting with https://www.example.com/ will be replaced with a local HTML page, while other URLs will load normally." + }, + { + "name": "custom_resource_loading_explain", + "value": "Image URLs in jpg and png formats load normally on Wi-Fi networks, and when not connected to Wi-Fi, a local placeholder image is loaded." + }, + { + "name": "url_can_not_be_null", + "value": "The content of the URL cannot be empty!" + } + ] +} \ No newline at end of file diff --git a/entry/src/main/resources/base/media/background.png b/entry/src/main/resources/base/media/background.png new file mode 100644 index 0000000000000000000000000000000000000000..923f2b3f27e915d6871871deea0420eb45ce102f Binary files /dev/null and b/entry/src/main/resources/base/media/background.png differ diff --git a/entry/src/main/resources/base/media/foreground.png b/entry/src/main/resources/base/media/foreground.png new file mode 100644 index 0000000000000000000000000000000000000000..97014d3e10e5ff511409c378cd4255713aecd85f Binary files /dev/null and b/entry/src/main/resources/base/media/foreground.png differ diff --git a/entry/src/main/resources/base/media/ic_arrow.svg b/entry/src/main/resources/base/media/ic_arrow.svg new file mode 100644 index 0000000000000000000000000000000000000000..ff6293ce432d79ed6c50d61d430ee79fe2e2dad2 --- /dev/null +++ b/entry/src/main/resources/base/media/ic_arrow.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + diff --git a/entry/src/main/resources/base/media/layered_image.json b/entry/src/main/resources/base/media/layered_image.json new file mode 100644 index 0000000000000000000000000000000000000000..fb49920440fb4d246c82f9ada275e26123a2136a --- /dev/null +++ b/entry/src/main/resources/base/media/layered_image.json @@ -0,0 +1,7 @@ +{ + "layered-image": + { + "background" : "$media:background", + "foreground" : "$media:foreground" + } +} \ No newline at end of file diff --git a/entry/src/main/resources/base/media/startIcon.png b/entry/src/main/resources/base/media/startIcon.png new file mode 100644 index 0000000000000000000000000000000000000000..205ad8b5a8a42e8762fbe4899b8e5e31ce822b8b Binary files /dev/null and b/entry/src/main/resources/base/media/startIcon.png differ diff --git a/entry/src/main/resources/base/profile/backup_config.json b/entry/src/main/resources/base/profile/backup_config.json new file mode 100644 index 0000000000000000000000000000000000000000..78f40ae7c494d71e2482278f359ec790ca73471a --- /dev/null +++ b/entry/src/main/resources/base/profile/backup_config.json @@ -0,0 +1,3 @@ +{ + "allowToBackupRestore": true +} \ No newline at end of file diff --git a/entry/src/main/resources/base/profile/main_pages.json b/entry/src/main/resources/base/profile/main_pages.json new file mode 100644 index 0000000000000000000000000000000000000000..1898d94f58d6128ab712be2c68acc7c98e9ab9ce --- /dev/null +++ b/entry/src/main/resources/base/profile/main_pages.json @@ -0,0 +1,5 @@ +{ + "src": [ + "pages/Index" + ] +} diff --git a/entry/src/main/resources/base/profile/route_map.json b/entry/src/main/resources/base/profile/route_map.json new file mode 100644 index 0000000000000000000000000000000000000000..6c4d05260ac43d445387eef17b9b1081a14cc893 --- /dev/null +++ b/entry/src/main/resources/base/profile/route_map.json @@ -0,0 +1,29 @@ +{ + "routerMap": [ + { + "name": "RedirectRequest", + "pageSourceFile": "src/main/ets/Interceptors/RedirectRequestInterceptor/view/RedirectRequestView.ets", + "buildFunction": "RedirectRequestBuilder" + }, + { + "name": "PageWhitelist", + "pageSourceFile": "src/main/ets/Interceptors/PageWhitelistInterceptor/view/PageWhitelistView.ets", + "buildFunction": "PageWhitelistBuilder" + }, + { + "name": "LocalResource", + "pageSourceFile": "src/main/ets/Interceptors/LocalResourceInterceptor/view/LocalResourceView.ets", + "buildFunction": "LocalResourceBuilder" + }, + { + "name": "CustomLoadingStrategy", + "pageSourceFile": "src/main/ets/Interceptors/CustomLoadingStrategyInterceptor/view/CustomLoadingStrategyView.ets", + "buildFunction": "CustomLoadingStrategyBuilder" + }, + { + "name": "CommonHeader", + "pageSourceFile": "src/main/ets/Interceptors/CommonHeaderInterceptor/view/CommonHeaderView.ets", + "buildFunction": "CommonHeaderBuilder" + } + ] +} \ No newline at end of file diff --git a/entry/src/main/resources/dark/element/color.json b/entry/src/main/resources/dark/element/color.json new file mode 100644 index 0000000000000000000000000000000000000000..79b11c2747aec33e710fd3a7b2b3c94dd9965499 --- /dev/null +++ b/entry/src/main/resources/dark/element/color.json @@ -0,0 +1,8 @@ +{ + "color": [ + { + "name": "start_window_background", + "value": "#000000" + } + ] +} \ No newline at end of file diff --git a/entry/src/main/resources/en_US/element/string.json b/entry/src/main/resources/en_US/element/string.json new file mode 100644 index 0000000000000000000000000000000000000000..c466065967b49b169903df509c3cf87d91d3a3fa --- /dev/null +++ b/entry/src/main/resources/en_US/element/string.json @@ -0,0 +1,116 @@ +{ + "string": [ + { + "name": "module_desc", + "value": "module description" + }, + { + "name": "EntryAbility_desc", + "value": "description" + }, + { + "name": "EntryAbility_label", + "value": "WebInterceptor" + }, + { + "name": "home_page_title", + "value": "Web Interception" + }, + { + "name": "request_redirect", + "value": "Request redirection" + }, + { + "name": "configure_whitelist", + "value": "Configure whitelist" + }, + { + "name": "local_resource_replacement", + "value": "Local resource replacement" + }, + { + "name": "custom_resource_loading_strategy", + "value": "Custom resource loading strategy" + }, + { + "name": "based_on_onLoadIntercept", + "value": "Request interception based on onLoadIntercept" + }, + { + "name": "based_on_onInterceptRequest", + "value": "Request interception based on onInterceptRequest" + }, + { + "name": "based_on_WebSchemeHandler", + "value": "Request interception based on WebSchemeHandler" + }, + { + "name": "configure_common_request_header", + "value": "Configure common request header" + }, + { + "name": "url_input_alert", + "value": "Please input url..." + }, + { + "name": "origin_url", + "value": "Origin url" + }, + { + "name": "redirect_url", + "value": "Redirect url" + }, + { + "name": "load_web_page", + "value": "Load web page" + }, + { + "name": "white_list_url", + "value": "Whitelist url" + }, + { + "name": "loading_url", + "value": "Loading url" + }, + { + "name": "white_list_url_explain", + "value": "URLs configured in the whitelist will be opened through the in-app ArkWeb component, while other URLs will be opened in the browser. For multiple whitelist URLs, please separate them with ','." + }, + { + "name": "open_in_browser_title", + "value": "Allow opening the link in a browser?" + }, + { + "name": "open_in_browser_message", + "value": "The URL being loaded is not in the whitelist. Do you want to open it in the browser?" + }, + { + "name": "cancel", + "value": "Cancel" + }, + { + "name": "ok", + "value": "OK" + }, + { + "name": "check_the_url", + "value": "Please check if the loaded URL uses the http or https protocol!" + }, + { + "name": "request_url", + "value": "Request url" + }, + { + "name": "local_resource_replace_explain", + "value": "URLs starting with https://www.example.com/ will be replaced with a local HTML page, while other URLs will load normally." + }, + { + "name": "custom_resource_loading_explain", + "value": "Image URLs in jpg and png formats load normally on Wi-Fi networks, and when not connected to Wi-Fi, a local placeholder image is loaded." + }, + { + "name": "url_can_not_be_null", + "value": "The content of the URL cannot be empty!" + } + ] +} \ No newline at end of file diff --git a/entry/src/main/resources/rawfile/index.html b/entry/src/main/resources/rawfile/index.html new file mode 100644 index 0000000000000000000000000000000000000000..8e9dc6df3cd1dcbebe2ecab24a49ef08dc7ce038 --- /dev/null +++ b/entry/src/main/resources/rawfile/index.html @@ -0,0 +1,56 @@ + + + + + + + + +
+

onInterceptRequest test

+

Replace the URL with local resources

+
+
+ Mountain +
+ + \ No newline at end of file diff --git a/entry/src/main/resources/rawfile/mountain.png b/entry/src/main/resources/rawfile/mountain.png new file mode 100644 index 0000000000000000000000000000000000000000..7e25b15fad9b907caa1e2e2fa9c585377697fa66 Binary files /dev/null and b/entry/src/main/resources/rawfile/mountain.png differ diff --git a/entry/src/main/resources/rawfile/no_wlan.jpg b/entry/src/main/resources/rawfile/no_wlan.jpg new file mode 100644 index 0000000000000000000000000000000000000000..ed2881a849b03bbbcf1f73e81bdeac32060bd51f Binary files /dev/null and b/entry/src/main/resources/rawfile/no_wlan.jpg differ diff --git a/entry/src/main/resources/zh_CN/element/string.json b/entry/src/main/resources/zh_CN/element/string.json new file mode 100644 index 0000000000000000000000000000000000000000..c02589e39304ff40fb3e88c5357668bd98f755ee --- /dev/null +++ b/entry/src/main/resources/zh_CN/element/string.json @@ -0,0 +1,116 @@ +{ + "string": [ + { + "name": "module_desc", + "value": "module description" + }, + { + "name": "EntryAbility_desc", + "value": "description" + }, + { + "name": "EntryAbility_label", + "value": "WebInterceptor" + }, + { + "name": "home_page_title", + "value": "使用Web组件拦截请求" + }, + { + "name": "request_redirect", + "value": "请求重定向" + }, + { + "name": "configure_whitelist", + "value": "页面白名单配置" + }, + { + "name": "local_resource_replacement", + "value": "本地资源替换" + }, + { + "name": "custom_resource_loading_strategy", + "value": "自定义资源加载策略" + }, + { + "name": "based_on_onLoadIntercept", + "value": "基于onLoadIntercept的请求拦截" + }, + { + "name": "based_on_onInterceptRequest", + "value": "基于onInterceptRequest的请求拦截" + }, + { + "name": "based_on_WebSchemeHandler", + "value": "基于WebSchemeHandler的请求拦截" + }, + { + "name": "configure_common_request_header", + "value": "配置公共请求头" + }, + { + "name": "url_input_alert", + "value": "请输入url..." + }, + { + "name": "origin_url", + "value": "原始请求url" + }, + { + "name": "redirect_url", + "value": "重定向url" + }, + { + "name": "load_web_page", + "value": "加载Web页面" + }, + { + "name": "white_list_url", + "value": "白名单url" + }, + { + "name": "loading_url", + "value": "加载url" + }, + { + "name": "white_list_url_explain", + "value": "配置了白名单的url将通过应用内的ArkWeb组件打开,其余url将跳转至浏览器打开,配置多个白名单url请通过‘,’分隔。" + }, + { + "name": "open_in_browser_title", + "value": "允许应用在浏览器中打开链接?" + }, + { + "name": "open_in_browser_message", + "value": "加载的url不在白名单中,是否跳转到浏览器加载?" + }, + { + "name": "cancel", + "value": "取消" + }, + { + "name": "ok", + "value": "允许" + }, + { + "name": "check_the_url", + "value": "请检查加载的url是否满足http或https协议!" + }, + { + "name": "request_url", + "value": "请求url" + }, + { + "name": "local_resource_replace_explain", + "value": "以https://www.example.com/开头的url将被替换为本地html页面,其余格式url将正常加载。" + }, + { + "name": "custom_resource_loading_explain", + "value": "jpg和png格式的图片url在wifi网络下正常加载,在不连接wifi时加载本地占位图。" + }, + { + "name": "url_can_not_be_null", + "value": "url的内容不能为空!" + } + ] +} \ No newline at end of file diff --git a/hvigor/hvigor-config.json5 b/hvigor/hvigor-config.json5 new file mode 100644 index 0000000000000000000000000000000000000000..7a7ab8914d8db6ab89758e185df5855dffe88d04 --- /dev/null +++ b/hvigor/hvigor-config.json5 @@ -0,0 +1,23 @@ +{ + "modelVersion": "6.0.0", + "dependencies": { + }, + "execution": { + // "analyze": "normal", /* Define the build analyze mode. Value: [ "normal" | "advanced" | "ultrafine" | false ]. Default: "normal" */ + // "daemon": true, /* Enable daemon compilation. Value: [ true | false ]. Default: true */ + // "incremental": true, /* Enable incremental compilation. Value: [ true | false ]. Default: true */ + // "parallel": true, /* Enable parallel compilation. Value: [ true | false ]. Default: true */ + // "typeCheck": false, /* Enable typeCheck. Value: [ true | false ]. Default: false */ + // "optimizationStrategy": "memory" /* Define the optimization strategy. Value: [ "memory" | "performance" ]. Default: "memory" */ + }, + "logging": { + // "level": "info" /* Define the log level. Value: [ "debug" | "info" | "warn" | "error" ]. Default: "info" */ + }, + "debugging": { + // "stacktrace": false /* Disable stacktrace compilation. Value: [ true | false ]. Default: false */ + }, + "nodeOptions": { + // "maxOldSpaceSize": 8192 /* Enable nodeOptions maxOldSpaceSize compilation. Unit M. Used for the daemon process. Default: 8192*/ + // "exposeGC": true /* Enable to trigger garbage collection explicitly. Default: true*/ + } +} diff --git a/hvigorfile.ts b/hvigorfile.ts new file mode 100644 index 0000000000000000000000000000000000000000..3de010508a622fcbcd5a8422c4e2ac05e0133916 --- /dev/null +++ b/hvigorfile.ts @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2025 Huawei Device Co., Ltd. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { appTasks } from '@ohos/hvigor-ohos-plugin'; +import { getNode } from '@ohos/hvigor'; +import { runHeaderTask, rerunHeaderTask, stopHeaderTask } from './scripts/commandTask.ts'; + +const node: HvigorNode = getNode(__filename); + +// ==================== Header Server Task ==================== + +node.registerTask({ + name: 'startHeaderServer', + run() { + runHeaderTask(); + } +}) + +node.registerTask({ + name: 'restartHeaderServer', + run() { + rerunHeaderTask(); + } +}) + +node.registerTask({ + name: 'stopHeaderServer', + run() { + stopHeaderTask(); + } +}) + + +export default { + system: appTasks, /* Built-in plugin of Hvigor. It cannot be modified. */ + plugins: [] /* Custom plugin to extend the functionality of Hvigor. */ +} \ No newline at end of file diff --git a/oh-package.json5 b/oh-package.json5 new file mode 100644 index 0000000000000000000000000000000000000000..de6d59dbf4c1ef2715242f0cc34744e6075de0b8 --- /dev/null +++ b/oh-package.json5 @@ -0,0 +1,8 @@ +{ + "modelVersion": "6.0.0", + "description": "Please describe the basic information.", + "dependencies": { + }, + "devDependencies": { + } +} diff --git a/screenshots/device/CommonHeader.png b/screenshots/device/CommonHeader.png new file mode 100644 index 0000000000000000000000000000000000000000..30cb099ae7e1a4e93828b200a6cda7e12ea50629 Binary files /dev/null and b/screenshots/device/CommonHeader.png differ diff --git a/screenshots/device/CommonHeaderResult.png b/screenshots/device/CommonHeaderResult.png new file mode 100644 index 0000000000000000000000000000000000000000..6a531fa6b764e9872e47b073659fff0c77b83f2c Binary files /dev/null and b/screenshots/device/CommonHeaderResult.png differ diff --git a/screenshots/device/CustomLoading.png b/screenshots/device/CustomLoading.png new file mode 100644 index 0000000000000000000000000000000000000000..0a9ebb06bba9601119a7fdd219be3613a6b7dcc5 Binary files /dev/null and b/screenshots/device/CustomLoading.png differ diff --git a/screenshots/device/CustomLoadingResult.png b/screenshots/device/CustomLoadingResult.png new file mode 100644 index 0000000000000000000000000000000000000000..6c995a2d7b15c0cdc517baf52a266e5c880bf7fa Binary files /dev/null and b/screenshots/device/CustomLoadingResult.png differ diff --git a/screenshots/device/LocalResource.png b/screenshots/device/LocalResource.png new file mode 100644 index 0000000000000000000000000000000000000000..c04c7ede4023099b48989a33d935e48c6af8278c Binary files /dev/null and b/screenshots/device/LocalResource.png differ diff --git a/screenshots/device/LocalResourceResult.png b/screenshots/device/LocalResourceResult.png new file mode 100644 index 0000000000000000000000000000000000000000..083f220482586b6c5aac6b94eecebdef604274bb Binary files /dev/null and b/screenshots/device/LocalResourceResult.png differ diff --git a/screenshots/device/PageWhitelist.png b/screenshots/device/PageWhitelist.png new file mode 100644 index 0000000000000000000000000000000000000000..3c26d24601f6ff3b0a596a7c33b2ebd3ebbaea96 Binary files /dev/null and b/screenshots/device/PageWhitelist.png differ diff --git a/screenshots/device/PageWhitelistResult.png b/screenshots/device/PageWhitelistResult.png new file mode 100644 index 0000000000000000000000000000000000000000..a880bd85ef29f6ead4dd4ce50eca9d7a098e494d Binary files /dev/null and b/screenshots/device/PageWhitelistResult.png differ diff --git a/screenshots/device/RedirectRequest.png b/screenshots/device/RedirectRequest.png new file mode 100644 index 0000000000000000000000000000000000000000..0bb5bc0323a026cab238b095040e4771e0d779a1 Binary files /dev/null and b/screenshots/device/RedirectRequest.png differ diff --git a/screenshots/device/RedirectRequestResult.png b/screenshots/device/RedirectRequestResult.png new file mode 100644 index 0000000000000000000000000000000000000000..6085acbe71b5979355977b5e308e5866b770eb30 Binary files /dev/null and b/screenshots/device/RedirectRequestResult.png differ diff --git a/screenshots/device_en/CommonHeaderResult_en.png b/screenshots/device_en/CommonHeaderResult_en.png new file mode 100644 index 0000000000000000000000000000000000000000..6a531fa6b764e9872e47b073659fff0c77b83f2c Binary files /dev/null and b/screenshots/device_en/CommonHeaderResult_en.png differ diff --git a/screenshots/device_en/CommonHeader_en.png b/screenshots/device_en/CommonHeader_en.png new file mode 100644 index 0000000000000000000000000000000000000000..32f5656afe3c5e779bbed0665c95f0b28b19ca71 Binary files /dev/null and b/screenshots/device_en/CommonHeader_en.png differ diff --git a/screenshots/device_en/CustomLoadingResult_en.png b/screenshots/device_en/CustomLoadingResult_en.png new file mode 100644 index 0000000000000000000000000000000000000000..9160cbe6a5f709d5739e8c8b1a046d0d38bbece4 Binary files /dev/null and b/screenshots/device_en/CustomLoadingResult_en.png differ diff --git a/screenshots/device_en/CustomLoading_en.png b/screenshots/device_en/CustomLoading_en.png new file mode 100644 index 0000000000000000000000000000000000000000..888ea8ab54e78625d1d254da5d844fd0424256a7 Binary files /dev/null and b/screenshots/device_en/CustomLoading_en.png differ diff --git a/screenshots/device_en/LocalResourceResult_en.png b/screenshots/device_en/LocalResourceResult_en.png new file mode 100644 index 0000000000000000000000000000000000000000..bce16d204e44218653ae63f4210b961b225d6a80 Binary files /dev/null and b/screenshots/device_en/LocalResourceResult_en.png differ diff --git a/screenshots/device_en/LocalResource_en.png b/screenshots/device_en/LocalResource_en.png new file mode 100644 index 0000000000000000000000000000000000000000..1a406e61556fb8e2d645dec79cdfb00e0f7eabad Binary files /dev/null and b/screenshots/device_en/LocalResource_en.png differ diff --git a/screenshots/device_en/PageWhitelistResult_en.png b/screenshots/device_en/PageWhitelistResult_en.png new file mode 100644 index 0000000000000000000000000000000000000000..44ae77fb4df348bd53943ca8b7310342d7cc40aa Binary files /dev/null and b/screenshots/device_en/PageWhitelistResult_en.png differ diff --git a/screenshots/device_en/PageWhitelist_en.png b/screenshots/device_en/PageWhitelist_en.png new file mode 100644 index 0000000000000000000000000000000000000000..223bdc23148ffd063e5eb8c8aa593769ed72602b Binary files /dev/null and b/screenshots/device_en/PageWhitelist_en.png differ diff --git a/screenshots/device_en/RedirectRequestResult_en.png b/screenshots/device_en/RedirectRequestResult_en.png new file mode 100644 index 0000000000000000000000000000000000000000..45ecc4a4057aa3ea494f69166d0ed8eed51dc548 Binary files /dev/null and b/screenshots/device_en/RedirectRequestResult_en.png differ diff --git a/screenshots/device_en/RedirectRequest_en.png b/screenshots/device_en/RedirectRequest_en.png new file mode 100644 index 0000000000000000000000000000000000000000..f81429fef9a212600ec2612f2375f2915f95ec95 Binary files /dev/null and b/screenshots/device_en/RedirectRequest_en.png differ diff --git a/screenshots/readme/local_network_ip.png b/screenshots/readme/local_network_ip.png new file mode 100644 index 0000000000000000000000000000000000000000..e10ab8c9e2e56be68f03c592e1cbf00093adadf1 Binary files /dev/null and b/screenshots/readme/local_network_ip.png differ diff --git a/screenshots/readme/node_version.png b/screenshots/readme/node_version.png new file mode 100644 index 0000000000000000000000000000000000000000..0f181ca6a38121eb8ad080068439f7116f5c8a8c Binary files /dev/null and b/screenshots/readme/node_version.png differ diff --git a/screenshots/readme/server_url.png b/screenshots/readme/server_url.png new file mode 100644 index 0000000000000000000000000000000000000000..077feece2f808a50c46430cd8e2a63646d1d819e Binary files /dev/null and b/screenshots/readme/server_url.png differ diff --git a/screenshots/readme/start_server.png b/screenshots/readme/start_server.png new file mode 100644 index 0000000000000000000000000000000000000000..077feece2f808a50c46430cd8e2a63646d1d819e Binary files /dev/null and b/screenshots/readme/start_server.png differ diff --git a/scripts/commandTask.ts b/scripts/commandTask.ts new file mode 100644 index 0000000000000000000000000000000000000000..9342fe025025e033775fb6e63e0f25e0da32a298 --- /dev/null +++ b/scripts/commandTask.ts @@ -0,0 +1,200 @@ +/* + * Copyright (c) 2025 Huawei Device Co., Ltd. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const { execSync } = require('child_process'); +const os = require('os'); + +const IPV4_REGEX = /^\d+\.\d+\.\d+\.\d+$/; +const LOOPBACK_PREFIX = '127.'; +let headerServerFlag: boolean = false; + +function pickValidIp(candidates: string[] = []): string | undefined { + return candidates + .map(ip => ip?.trim()) + .find(ip => ip && !ip.startsWith(LOOPBACK_PREFIX) && IPV4_REGEX.test(ip)); +} + +function safeExec(command: string): string | undefined { + try { + return execSync(command, { encoding: 'utf8', shell: true }).trim(); + } catch { + return undefined; + } +} + +function getIpFromInterfaces(): string | undefined { + try { + const interfaces = os.networkInterfaces(); + for (const iface of Object.values(interfaces)) { + if (!iface) { + continue; + } + const hit = iface.find(info => info.family === 'IPv4' && !info.internal); + if (hit) { + return hit.address; + } + } + } catch (err) { + console.error('Failed to get network IP from interfaces:', err); + } + return undefined; +} + +function getIpFromWindowsCommand(): string | undefined { + const output = safeExec('for /f "tokens=2 delims=:" %a in (\'ipconfig ^| findstr /i "IPv4"\') do @echo %a'); + return output ? pickValidIp(output.split('\n')) : undefined; +} + +function getIpFromUnixCommands(): string | undefined { + const hostnameResult = safeExec('hostname -I'); + const hostnameIp = hostnameResult ? pickValidIp(hostnameResult.split(/\s+/)) : undefined; + if (hostnameIp) { + return hostnameIp; + } + + const ipCommandResult = safeExec("ip -4 addr show | grep -oP '(?<=inet\\s)\\d+(\\.\\d+){3}'"); + return ipCommandResult ? pickValidIp(ipCommandResult.split(/\s+/)) : undefined; +} + +function getIpFromSystemCommands(): string | undefined { + try { + return os.platform() === 'win32' ? getIpFromWindowsCommand() : getIpFromUnixCommands(); + } catch (err) { + console.error('Failed to get IP via system command:', err); + return undefined; + } +} + +/** + * Get local network IP address + * Returns the first non-internal IPv4 address found + */ +export function getLocalNetworkIP(): string { + const sources = [getIpFromInterfaces, getIpFromSystemCommands]; + for (const source of sources) { + const ip = source(); + if (ip) { + return ip; + } + } + + // Ultimate fallback + return '127.0.0.1'; +} + +/** + * Install server dependencies + */ +export function installServer() { + try { + const result = execSync('npm i', {cwd: './LocalServer', encoding: 'utf8' }); + console.log(result); + } catch (err) { + console.error('Execution failed:', err.message); + } +} + +/** + * Start header server + */ +export function startHeaderServer() { + try { + headerServerFlag = true; + const result = execSync('npm run forever:start', {cwd: './LocalServer', encoding: 'utf8' }); + console.log(result); + } catch (err) { + console.error('Execution failed:', err.message); + } +} + +/** + * Restart header server + */ +export function restartHeaderServer() { + try { + const result = execSync('npm run forever:restart', {cwd: './LocalServer', encoding: 'utf8' }); + console.log(result); + } catch (err) { + console.error('Execution failed:', err.message); + } +} + +/** + * Stop header server + */ +export function stopHeaderServer() { + try { + headerServerFlag = false; + const result = execSync('npm run forever:stop', {cwd: './LocalServer', encoding: 'utf8' }); + console.log(result); + } catch (err) { + console.error('Execution failed:', err.message); + } +} + +// ==================== Combined task ==================== + +/** + * Start the header server task + * Includes: installing dependencies, starting the server + */ +export function runHeaderTask() { + try { + const localIP = getLocalNetworkIP(); + console.log(`\n=== Header Server Configuration ===`); + console.log(`Local Network IP: ${localIP}`); + console.log(`Server URL: http://${localIP}:8081/api/headers`); + console.log(`Please ensure your device is on the same network and use this IP in the app.\n`); + + if (!headerServerFlag) { + installServer(); + startHeaderServer(); + } else { + restartHeaderServer(); + } + } catch (err) { + console.error('Execution failed:', err.message); + } +} + +/** + * Restart the header server task + * Includes: restarting the server + */ +export function rerunHeaderTask() { + try { + const localIP = getLocalNetworkIP(); + console.log(`\n=== Header Server Configuration ===`); + console.log(`Local Network IP: ${localIP}`); + console.log(`Server URL: http://${localIP}:8081/api/headers`); + console.log(`Please ensure your device is on the same network and use this IP in the app.\n`); + + restartHeaderServer(); + } catch (err) { + console.error('Execution failed:', err.message); + } +} + +/** + * Stop the header server task + * Includes: stopping the server + */ +export function stopHeaderTask() { + try { + stopHeaderServer(); + } catch (err) { + console.error('Execution failed:', err.message); + } +} \ No newline at end of file