commit 353859dfbc159f02542058b63c431447a0e443e8 Author: RyrieNorth <2586649501@qq.com> Date: Wed Sep 9 18:10:20 2026 +0800 first commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..4941ac7 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +.git +.gitignore +.env +data +docs +functions +location-spoofer.js +README.md + diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d66d759 --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +TOKEN=replace-with-a-long-random-token (such as openssl rand -hex 32) +AMAP_KEY= +PORT=8080 +LISTEN_ADDRESS=0.0.0.0 + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8d05a68 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +data/ +.env +node_modules/ +*.log +.env +data/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..723c501 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +FROM node:22-alpine + +WORKDIR /app +COPY --chown=node:node package.json server.mjs ./ +COPY --chown=node:node public ./public +RUN mkdir -p /app/data && chown node:node /app/data + +ENV HOST=0.0.0.0 \ + PORT=8080 \ + DATA_DIR=/app/data + +USER node +EXPOSE 8080 +VOLUME ["/app/data"] +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD wget -q -O - http://127.0.0.1:8080/healthz >/dev/null || exit 1 + +CMD ["node", "server.mjs"] + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1f66842 --- /dev/null +++ b/LICENSE @@ -0,0 +1,112 @@ +Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International Public License + +Copyright (c) 2026 akudamatata (https://github.com/akudamatata/iOS-Location-Spoofer-Web) + +================================================================================ +【特别声明 / Special Notice】 +本项目完全免费开源,仅供个人学习、技术研究与地图接口调试使用。 + +「严禁以任何形式进行二次售卖、转售、商业收费代搭建、打包牟利等商业行为」 + +若您是通过闲鱼、淘宝、拼多多、微信群等任何付费渠道获取本项目的部署服务或源码: +👉 您已被欺诈,请立即向平台申请退款并举报商家! +================================================================================ + +By exercising the Licensed Rights (defined below), You accept and agree to be bound by the terms and conditions of this Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International Public License ("Public License"). To the extent this Public License may be interpreted as a contract, You are granted the Licensed Rights in consideration of Your acceptance of these terms and conditions, and the Licensor grants You such rights in consideration of benefits the Licensor receives from making the Licensed Material available under these terms and conditions. + +Section 1 -- Definitions. + +a. Adapted Material means material subject to Copyright and Similar Rights that is derived from or based upon the Licensed Material and in which the Licensed Material is translated, altered, arranged, transformed, or otherwise modified in a manner requiring permission under the Copyright and Similar Rights held by the Licensor. For purposes of this Public License, where the Licensed Material is a musical work, performance, or sound recording, Adapted Material is always produced where the Licensed Material is synched in timed relation with a moving image. +b. Adapter's License means the license You apply to Your Copyright and Similar Rights in Your contributions to Adapted Material in accordance with the terms and conditions of this Public License. +c. BY-NC-SA Compatible License means a license listed at creativecommons.org/compatiblelicenses, approved by Creative Commons as essentially the equivalent of this Public License. +d. Commercial means primarily intended for or directed towards commercial advantage or monetary compensation. +e. Copyright and Similar Rights means copyright and/or similar rights closely related to copyright including, without limitation, performance, broadcast, sound recording, and Sui Generis Database Rights, without regard to how the rights are labeled or categorized. For purposes of this Public License, the rights specified in Section 2(b)(1)-(2) are not Copyright and Similar Rights. +f. Effective Technological Measures means those measures that, in the absence of proper authority, may not be circumvented under laws fulfilling obligations under Article 11 of the WIPO Copyright Treaty adopted on December 20, 1996, and/or similar international agreements. +g. Exceptions and Limitations means fair use, fair dealing, and/or any other exception or limitation to Copyright and Similar Rights that applies to Your use of the Licensed Material. +h. Licensed Material means the artistic or literary work, database, or other material to which the Licensor applied this Public License. +i. Licensed Rights means the rights granted to You subject to the terms and conditions of this Public License, which are limited to all Copyright and Similar Rights that apply to Your use of the Licensed Material and that the Licensor has authority to license. +j. Licensor means the individual(s) or entity(ies) granting rights under this Public License. +k. NonCommercial means not primarily intended for or directed towards commercial advantage or monetary compensation. +l. Share means to provide material to the public by any means or process that requires permission under the Licensed Rights, such as reproduction, public display, public performance, distribution, dissemination, communication, or importation, and to make material available to the public including in ways that members of the public may access the material from a place and at a time individually chosen by them. +m. ShareAlike means the requirement that Adapted Material must be licensed under the same Public License or a BY-NC-SA Compatible License. +n. Sui Generis Database Rights means rights other than copyright resulting from Directive 96/9/EC of the European Parliament and of the Council of 11 March 1996 on the legal protection of databases, as amended and/or succeeded, as well as other essentially equivalent rights anywhere in the world. +o. You means the individual or entity exercising the Licensed Rights under this Public License. Your has a corresponding meaning. + +Section 2 -- Scope. + +a. License grant. +1. Subject to the terms and conditions of this Public License, the Licensor hereby grants You a worldwide, royalty-free, non-sublicensable, non-exclusive, irrevocable license to exercise the Licensed Rights in the Licensed Material to: +A. reproduce and Share the Licensed Material, in whole or in part, for NonCommercial purposes only; and +B. produce, reproduce, and Share Adapted Material for NonCommercial purposes only. +2. Exceptions and Limitations. For the avoidance of doubt, where Exceptions and Limitations apply to Your use, this Public License does not apply, and You do not need to comply with its terms and conditions. +3. Term. The term of this Public License is specified in Section 6(a). +4. Media and formats; technical modifications allowed. The Licensor authorizes You to exercise the Licensed Rights in all media and formats whether now known or hereafter created, and to make technical modifications necessary to do so. The Licensor waives and/or agrees not to assert any right or authority to forbid You from making technical modifications necessary to exercise the Licensed Rights, including technical modifications necessary to circumvent Effective Technological Measures. For purposes of this Public License, simply making modifications authorized by this Section 2(a)(4) never produces Adapted Material. +5. Downstream recipients. +A. Offer from the Licensor -- Licensed Material. Every recipient of the Licensed Material automatically receives an offer from the Licensor to exercise the Licensed Rights under the terms and conditions of this Public License. +B. Additional offer from the Licensor -- Adapted Material. Every recipient of Adapted Material from You automatically receives an offer from the Licensor to exercise the Licensed Rights in the Adapted Material under the conditions of the Adapter's License You apply. +C. No downstream restrictions. You may not offer or impose any additional or different terms or conditions on, or apply any Effective Technological Measures to, the Licensed Material if doing so restricts exercise of the Licensed Rights by any recipient of the Licensed Material. +6. No endorsement. Nothing in this Public License constitutes or may be construed as permission to assert or imply that You are, or that Your use of the Licensed Material is, connected with, or sponsored, endorsed, or granted official status by, the Licensor or others designated to receive attribution as provided in Section 3(a)(1)(A)(i). + +b. Other rights. +1. Moral rights, such as the right of integrity, are not licensed under this Public License, nor are publicity, privacy, and/or other similar personality rights; however, to the extent possible, the Licensor waives and/or agrees not to assert any such rights held by the Licensor to the limited extent necessary to allow You to exercise the Licensed Rights, but not otherwise. +2. Patent and trademark rights are not licensed under this Public License. +3. To the extent possible, the Licensor waives any right to collect royalties from You for the exercise of the Licensed Rights, whether directly or through a collecting society under any voluntary or waivable statutory or compulsory licensing scheme. In all other cases the Licensor expressly reserves any right to collect such royalties, including when the Licensed Material is used other than for NonCommercial purposes. + +Section 3 -- License Conditions. + +Your exercise of the Licensed Rights is expressly made subject to the following conditions. + +a. Attribution. +1. If You Share the Licensed Material (including in modified form), You must: +A. retain the following if it is supplied by the Licensor with the Licensed Material: +i. identification of the creator(s) of the Licensed Material and any others designated to receive attribution, in any reasonable manner requested by the Licensor (including by pseudonym if designated); +ii. a copyright notice; +iii. a notice that refers to this Public License; +iv. a notice that refers to the disclaimer of warranties; +v. a URI or hyperlink to the Licensed Material to the extent reasonably practicable; +B. indicate if You modified the Licensed Material and retain an indication of any previous modifications; and +C. indicate the Licensed Material is licensed under this Public License, and include the text of, or the URI or hyperlink to, this Public License. +2. You may satisfy the conditions in Section 3(a)(1) in any reasonable manner based on the medium, means, and context in which You Share the Licensed Material. For example, it may be reasonable to satisfy the conditions by providing a URI or hyperlink to a resource that includes the required information. +3. If requested by the Licensor, You must remove any of the information required by Section 3(a)(1)(A) to the extent reasonably practicable. + +b. ShareAlike. +In addition to the conditions in Section 3(a), if You Share Adapted Material You produce, the following conditions also apply. +1. The Adapter's License You apply must be a Creative Commons license with the same License Elements, this version or later, or a BY-NC-SA Compatible License. +2. You must include the text of, or the URI or hyperlink to, the Adapter's License You apply. You may satisfy this condition in any reasonable manner based on the medium, means, and context in which You Share Adapted Material. +3. You may not offer or impose any additional or different terms or conditions on, or apply any Effective Technological Measures to, Adapted Material that restrict exercise of the rights granted under the Adapter's License You apply. + +Section 4 -- Sui Generis Database Rights. + +Where the Licensed Rights include Sui Generis Database Rights that apply to Your use of the Licensed Material: +a. for the avoidance of doubt, Section 2(a)(1) grants You the right to extract, reuse, reproduce, and Share all or a substantial portion of the contents of the database for NonCommercial purposes only; +b. if You include all or a substantial portion of the database contents in a database in which You have Sui Generis Database Rights, then the database in which You have Sui Generis Database Rights (but not its individual contents) is Adapted Material, including for purposes of Section 3(b); and +c. You must comply with the conditions in Section 3(a) if You Share all or a substantial portion of the contents of the database. +For the avoidance of doubt, this Section 4 supplements and does not replace Your obligations under this Public License where the Licensed Rights include other Copyright and Similar Rights. + +Section 5 -- Disclaimer of Warranties and Limitation of Liability. + +a. Unless otherwise separately undertaken by the Licensor, to the extent possible, the Licensor offers the Licensed Material as-is and as-available, and makes no representations or warranties of any kind concerning the Licensed Material, whether express, implied, statutory, or other. This includes, without limitation, warranties of title, merchantability, fitness for a particular purpose, non-infringement, absence of latent or other defects, accuracy, or the presence or absence of errors, whether or not known or discoverable. Where disclaimers of warranties are not allowed in full or in part, this disclaimer may not apply to You. +b. To the extent possible, in no event will the Licensor be liable to You on any legal theory (including, without limitation, negligence) or otherwise for any direct, special, indirect, incidental, consequential, punitive, exemplary, or other losses, costs, expenses, or damages arising out of this Public License or use of the Licensed Material, even if the Licensor has been advised of the possibility of such losses, costs, expenses, or damages. Where a limitation of liability is not allowed in full or in part, this limitation may not apply to You. +c. The disclaimer of warranties and limitation of liability provided above shall be interpreted in a manner that, to the extent possible, most closely approximates an absolute disclaimer and waiver of all liability. + +Section 6 -- Term and Termination. + +a. This Public License applies for the term of the Copyright and Similar Rights licensed here. However, if You fail to comply with this Public License, then Your rights under this Public License terminate automatically. +b. Where Your right to use the Licensed Material has terminated under Section 6(a), it reinstates: +1. automatically as of the date the violation is cured, provided it is cured within 30 days of Your discovery of the violation; or +2. upon express reinstatement by the Licensor. +For the avoidance of doubt, this Section 6(b) does not affect any right the Licensor may have to seek remedies for Your violations of this Public License. +c. For the avoidance of doubt, the Licensor may also offer the Licensed Material under separate terms or conditions or stop distributing the Licensed Material at any time; however, doing so will not terminate this Public License. +d. Sections 1, 5, 6, 7, and 8 survive termination of this Public License. + +Section 7 -- Other Terms and Conditions. + +a. The Licensor shall not be bound by any additional or different terms or conditions communicated by You unless expressly agreed. +b. Any arrangements, understandings, or agreements regarding the Licensed Material not stated here are separate from and independent of the terms and conditions of this Public License. + +Section 8 -- Interpretation. + +a. For the avoidance of doubt, this Public License does not, and shall not be interpreted to, reduce, limit, restrict, or impose conditions on any use of the Licensed Material that could lawfully be made without permission under this Public License. +b. To the extent possible, if any provision of this Public License is deemed unenforceable, it shall be automatically reformed to the minimum extent necessary to make it enforceable. If the provision cannot be reformed, it shall be severed from this Public License without affecting the enforceability of the remaining terms and conditions. +c. No term or condition of this Public License will be waived and no failure to comply consented to unless expressly agreed to by the Licensor. +d. Nothing in this Public License constitutes or may be interpreted as a limitation upon, or waiver of, any privileges and immunities that apply to the Licensor or You, including from the legal processes of any jurisdiction or authority. diff --git a/README.md b/README.md new file mode 100644 index 0000000..f47dd20 --- /dev/null +++ b/README.md @@ -0,0 +1,231 @@ +# iOS-Location-Spoofer-Web + +[![License: CC BY-NC-SA 4.0](https://img.shields.io/badge/License-CC%20BY--NC--SA%204.0-lightgrey.svg)](https://creativecommons.org/licenses/by-nc-sa/4.0/) + +> ### ⚠️ 【防骗与严禁倒卖声明】 +> 本项目为 **100% 免费开源项目**(唯一官方开源仓库:[akudamatata/iOS-Location-Spoofer-Web](https://github.com/akudamatata/iOS-Location-Spoofer-Web)),遵循 **CC BY-NC-SA 4.0** 开源许可协议。 +> **「严禁任何个人或组织以任何形式进行二次售卖、转售、商业收费代搭建、打包牟利等行为」**。 +> 若您是通过闲鱼、淘宝、拼多多、付费微信群等任何渠道付费购买获得本项目的,**您已被欺诈,请立即向购买平台申请退款并举报不良商家!** + +📱 基于 **Shadowrocket MITM** 方案的 iOS GPS 模拟定位 Web 管理面板(**个人单用户自建专属**)。 + +采用 Apple 2026 **Liquid Glass(液态玻璃)** 视觉美学设计,全屏地图选点,后端与核心规则支持 100% 独立自建托管。 + +--- + +## 🌟 核心特性 + +| | 特性 | 说明 | +|---|---|---| +| 🗺 | **多地图切换** | CartoDB / Esri 卫星 / 高德地图 / 高德卫星,支持国内外全域定位 | +| 🎯 | **准星锁定** | 滑动地图对准目标,点击锁定后地图显示蓝色图钉标记已生效坐标 | +| 📍 | **当前位置** | 一键回到当前物理位置并自动纠偏对齐 | +| 🔒 | **后端完全自建** | 内置自托管 `location-spoofer.js` 与规则模块,无需依赖任何外部规则订阅 | +| 👤 | **个人专属架构** | 专为个人单用户设计的极简 Serverless 架构,单实例专属,零维护成本 | +| ⭐ | **智能收藏夹** | 毛玻璃面板,保存常用地点;已收藏位置星标实时高亮,支持一键切换与取消 | +| 🔢 | **高级参数** | 可调节海拔、水平精度、垂直精度(支持按地形自动获取海拔高度)| +| 🔍 | **坐标直跳** | 搜索框直接粘贴经纬度(如 `39.9087, 116.3975`)即刻精准跳转 | +| 🌙 | **深色模式** | 设置面板内一键切换深色/浅色,偏好自动记忆 | +| 📲 | **PWA 支持** | Safari「添加到主屏幕」后全屏运行,体验对齐原生 iOS App | +| ⚡️ | **WLOC 最小改写** | 采用最小改写策略(Minimal Rewrite)与滑窗扫描兜底,稳定适配最新 iOS 系统 | + +### 📸 界面预览 + + + + + + + + + + + + +
+ 全屏地图选点
+ 全屏地图选点 +
+ 智能收藏夹
+ 智能收藏夹 +
+ 设置与小火箭配置
+ 设置与小火箭配置 +
+ 多图层图源切换
+ 多图层图源切换 +
+ 卫星图与高级参数
+ 卫星图与高级参数 +
+ 地点搜索与历史
+ 地点搜索与历史 +
+ +--- + +## 📱 系统与客户端兼容性矩阵 + +| 平台 / 系统 / 软件 | 版本范围 | 支持状态 | 说明 | +|---|---|---|---| +| **iOS / iPadOS** | iOS 12.0 ~ iOS 26.x | 🟢 完美支持 | 覆盖所有主流正式版系统,零 BigInt 纯原生实现,稳定运行 | +| **iOS / iPadOS** | iOS 27 beta 1 ~ beta 5 | 🟢 完美支持 | 已跟进 WLOC 最小改写策略与封装扫描兜底 | +| **iOS / iPadOS** | iOS 27 beta 6 及以上 | ⚠️ 系统受限 | 苹果在系统定位组件开启了强 TLS 证书固定(Pinning),目前所有 MITM 方案均受限 | +| **Shadowrocket** | v2.2.x 及以上版本 | 🟢 完美支持 | 需开启 HTTPS 解密并信任 CA 证书 | + +--- + +## 🌐 外部服务与数据隐私边界 + +为了在浏览器端提供流畅的地图交互和选点体验,前端 Web 面板在必要时会直接请求以下公开 WebGIS 与基础服务: + +1. **地图底图瓦片 (Map Tiles)**: + - 高德地图 / 高德卫星(国内推荐,加载高德官方公开瓦片) + - CartoDB / Esri World Imagery(国外底图与高精度卫星影像) +2. **地点搜索接口 (Geocoding)**: + - 高德 Web 服务 API(如配置 `AMAP_KEY`,用于国内地名关键字联想) + - OpenStreetMap Nominatim(未配置高德 Key 时的国际搜索备用源) +3. **地形海拔查询**: + - Open-Meteo Elevation API(点击地图时自动获取目标坐标的真实地形海拔) +4. **静态前端资源 CDN**: + - `unpkg.com`(Leaflet 地图基础库)、`Google Fonts`(Inter 字体) + +> 💡 **数据安全声明**:所有自建配置、Token 鉴权、当前定位坐标与收藏夹数据均仅保存在您私有部署的 Cloudflare KV 中,绝不向任何第三方上传您的私有定位坐标记录。 + +--- + +## 🐳 Docker 自托管 + +Docker 版本内置一个零依赖 Node.js 后端,用本地 JSON 文件替代 Cloudflare Pages Functions 与 KV,原有网页、API 路径和 Shadowrocket 模块链接保持不变。 + +```bash +git clone https://github.com/你的账号/iOS-Location-Spoofer-Web.git +cd iOS-Location-Spoofer-Web +cp .env.example .env +``` + +编辑 `.env`,至少设置一个足够长的随机 `TOKEN`,然后启动: + +```bash +docker compose up -d --build +curl http://127.0.0.1:8080/healthz +``` + +默认监听 `0.0.0.0:8080`,坐标和收藏夹保存在 Docker volume `spoofer_data`。生产环境请在容器前配置 Nginx、Caddy 或其他 HTTPS 反向代理,并把 `X-Forwarded-Proto` 和 `X-Forwarded-Host` 传给应用,以便生成正确的 Shadowrocket 模块链接。 + +环境变量: + +| 变量 | 必填 | 默认值 | 说明 | +|---|---:|---|---| +| `TOKEN` | 是 | 无 | 网页和所有定位 API 的访问密码;未设置时服务拒绝启动 | +| `AMAP_KEY` | 否 | 空 | 高德 Web 服务 Key | +| `PORT` | 否 | `8080` | 宿主机暴露端口 | +| `LISTEN_ADDRESS` | 否 | `0.0.0.0` | 宿主机监听地址;只供本机反代时可设为 `127.0.0.1` | + +升级时执行: + +```bash +git pull +docker compose up -d --build +``` + +--- + +## 🛠 快速部署 (Cloudflare Pages) + +本项目专为 **Cloudflare Pages** 设计,部署于全球边缘节点,实现 **零维护、零服务器成本、个人专属**。 + +### 1. 准备工作 +- 注册并登录 [Cloudflare](https://dash.cloudflare.com/) 账号。 +- 在 Cloudflare Dashboard 左侧菜单找到 **Workers & Pages** -> **KV**。 +- 创建一个新的 KV 命名空间,命名为 `SPOOFER_DATA`。 + +### 2. Fork 仓库 +点击右上角的 Fork,将本仓库 Fork 到您的 GitHub 账号下。 + +### 3. 创建 Pages 项目 +1. 在 Cloudflare Dashboard 侧边栏进入 **Workers & Pages** -> **Overview**,点击右上角 **Create application** (创建应用程序)。 +2. ⚠️ **关键:请务必点击顶部的「Pages (网页)」标签卡**(切勿停留在默认的 Workers 标签卡上),然后点击 **Connect to Git** (连接到 Git)。 +3. 授权连接您的 GitHub,选择您刚才 Fork 的仓库。 +4. 在构建设置 (Build settings) 页面: + - **Framework preset** (框架预设): 选择 `None` + - **Build command** (构建命令): 填写 `exit 0` + - **Build output directory** (构建输出目录): 填写 `public` +5. 展开 **Environment variables (advanced)** (环境变量),添加以下变量: + - `TOKEN`: 您的私有访问密码(必填,用于面板访问与接口鉴权,服务端已做安全脱敏保护) + - `AMAP_KEY`: 您的高德地图 Web 服务 Key(用于国内高精度地名搜索,可选但强烈推荐) +6. 点击 **Save and Deploy**(保存并部署)。首次部署由于尚未绑定 KV 会提示无法保存数据,这是正常的,请继续下一步。 + +### 4. 绑定 KV 命名空间 +1. 部署完成后,进入该 Pages 项目的详情页,点击顶部的 **Settings** -> **Functions**。 +2. 往下滚动找到 **KV namespace bindings**。 +3. 点击 **Add binding**: + - **Variable name (变量名称)**: 填入 `SPOOFER_DATA` (必须完全一致) + - **KV namespace (KV 命名空间)**: 选择您在第一步创建的 `SPOOFER_DATA`。 +4. 重新部署一次生效:回到该项目的 **Deployments (部署)** 页面,点击列表最上面一次部署右侧的 `...` 图标 -> **Retry deployment (重试部署)**。 + +部署完成后,您将获得一个类似 `https://your-project.pages.dev` 的专属域名,可直接通过手机访问面板! + +--- + +## 📲 Shadowrocket 配置指南 + +### 1. 添加为模块 (Module) + +1. 在手机浏览器打开面板,输入您的 Token 登录。 +2. 点击页面右上角 **「⚙️ 设置」** 图标,复制**模块链接**。 +3. 打开 Shadowrocket → 底部 **「配置」** 标签页 → 点击进入 **「模块 (Modules)」**。 +4. 点击右上角 **「+」** → 粘贴刚才复制的链接 → 点击**下载**。 +5. 确保下载好的 `iOS Location Spoofer` 模块开关处于**开启**状态。 + +### 2. 开启 HTTPS 解密与安装证书 + +点击当前配置文件进入详情 → **「HTTPS 解密」**: + +1. 开启 **「HTTPS 解密」** 开关 +2. 开启 **「通过 HTTP/2 进行中间人攻击 (MitM)」** 开关 +3. 点击 **「证书」** → **「生成新的 CA 证书」** → **「安装证书」** +4. 前往 iPhone **「设置 → 通用 → 关于本机 → 证书信任设置」**,找到 Shadowrocket 证书并**完全信任** + +### 3. 启动 VPN + +回到小火箭首页,开启 VPN 开关,模式保持 **「配置 (Config)」** 即可。 + +--- + +## 🧭 日常使用流程 + +1. **打开面板**:手机浏览器访问面板地址,输入 Token 登录(登录后 30 天内免密直接进入) +2. **选点**:拖动地图准星,或顶部搜索框输入地名 / 直接粘贴经纬度(如 `39.9087, 116.3975`) +3. **锁定**:点击 **「锁定」** 按钮,地图上出现蓝色图钉,提示"位置已锁定" +4. **刷新定位**:前往 iPhone **「设置 → 隐私与安全 → 定位服务」**,关闭后等 10 秒再重新开启 +5. **验证**:打开高德地图、微信或系统地图,此时模拟定位已顺利生效 ✅ + +> **换位置**:重复步骤 2-4 即可,无需重启小火箭。 +> **收藏常用地点**:锁定位置后点击准星旁的 ⭐ 星标即可收藏,再次点击实心星可取消收藏。 +> **夜间使用**:点击设置面板底部的「深色模式」开关,偏好自动记忆。 +> **添加到主屏幕 (PWA)**:在 Safari 中点击「分享」→「添加到主屏幕」,即可像 App 一样全屏使用。 + +--- + +## ⚖️ 项目声明与免责条款 + +1. **原创研发**:本项目全量源码(包括 Liquid Glass Web 前端设计、Cloudflare Pages Serverless 后端 API 体系、以及基于纯 JS 原生实现的 `location-spoofer.js` 核心代理拦截改写引擎)均为**100% 独立自主设计与研发编写**。 +2. **免责声明**:本项目仅供开发者用于地图开发测试、地理位置接口调试以及技术性学习研究,请勿用于非法用途。因违规使用产生的一切风险与后果由使用者自行承担。 + +--- + +## 📄 开源授权与使用条款 + +本项目采用 **[Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International (CC BY-NC-SA 4.0)](https://creativecommons.org/licenses/by-nc-sa/4.0/)** 许可协议。 + +**核心约束条款:** +* **署名 (Attribution)**:在衍生项目、教程或分享中必须保留原作者信息及本项目 GitHub 仓库链接。 +* **非商业性使用 (Non-Commercial)**:**「严禁以任何形式进行二次售卖、转售、商业收费代搭建、打包牟利等行为」**。 +* **相同方式共享 (Share-Alike)**:若您修改、转换或以此代码为基础进行创作,必须采用相同或兼容的 CC 协议进行开源共享。 + +--- + +## 🔗 友情链接 + +- [LINUX DO - 新的理想型社区](https://linux.do/) diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..552f524 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,21 @@ +services: + ios-location-spoofer: + build: . + image: ios-location-spoofer-web:local + container_name: ios-location-spoofer + restart: unless-stopped + environment: + TOKEN: ${TOKEN:?Set TOKEN in .env} + AMAP_KEY: ${AMAP_KEY:-} + ports: + - "${LISTEN_ADDRESS:-0.0.0.0}:${PORT:-8080}:8080" + volumes: + - spoofer_data:/app/data + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + +volumes: + spoofer_data: + diff --git a/docs/screenshots/1-map-picker.jpg b/docs/screenshots/1-map-picker.jpg new file mode 100644 index 0000000..53033b7 Binary files /dev/null and b/docs/screenshots/1-map-picker.jpg differ diff --git a/docs/screenshots/2-favorites.jpg b/docs/screenshots/2-favorites.jpg new file mode 100644 index 0000000..afae84d Binary files /dev/null and b/docs/screenshots/2-favorites.jpg differ diff --git a/docs/screenshots/3-shadowrocket-config.jpg b/docs/screenshots/3-shadowrocket-config.jpg new file mode 100644 index 0000000..1b025b1 Binary files /dev/null and b/docs/screenshots/3-shadowrocket-config.jpg differ diff --git a/docs/screenshots/4-map-layers.jpg b/docs/screenshots/4-map-layers.jpg new file mode 100644 index 0000000..6997e88 Binary files /dev/null and b/docs/screenshots/4-map-layers.jpg differ diff --git a/docs/screenshots/5-advanced-params.jpg b/docs/screenshots/5-advanced-params.jpg new file mode 100644 index 0000000..44b80b0 Binary files /dev/null and b/docs/screenshots/5-advanced-params.jpg differ diff --git a/docs/screenshots/6-search-history.jpg b/docs/screenshots/6-search-history.jpg new file mode 100644 index 0000000..264a66d Binary files /dev/null and b/docs/screenshots/6-search-history.jpg differ diff --git a/functions/_utils.js b/functions/_utils.js new file mode 100644 index 0000000..2e9f59a --- /dev/null +++ b/functions/_utils.js @@ -0,0 +1,44 @@ +/** + * iOS Location Spoofer Web + * + * Copyright (c) 2026 akudamatata (https://github.com/akudamatata/iOS-Location-Spoofer-Web) + * Licensed under CC BY-NC-SA 4.0 + * ⚠️【特别声明】:本项目完全免费开源,严禁以任何形式进行二次售卖、转售、商业收费代搭建! + */ + +export const DEFAULT_LOC = { + latitude: 39.90872, + longitude: 116.39748, + altitude: 44, + horizontalAccuracy: 39, + verticalAccuracy: 1000 +}; + +export function authOk(request, env) { + const url = new URL(request.url); + const token = url.searchParams.get('token'); + return !env.TOKEN || token === env.TOKEN; +} + +export function jsonResponse(data, status = 200) { + return new Response(JSON.stringify(data), { + status, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + 'Cache-Control': 'no-store' + } + }); +} + +export function errorResponse(message, status = 400) { + return jsonResponse({ error: message }, status); +} + +export function corsHeaders() { + return { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET,POST,DELETE,OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type' + }; +} diff --git a/functions/favorites/[id].js b/functions/favorites/[id].js new file mode 100644 index 0000000..379d758 --- /dev/null +++ b/functions/favorites/[id].js @@ -0,0 +1,34 @@ +/** + * iOS Location Spoofer Web + * + * Copyright (c) 2026 akudamatata (https://github.com/akudamatata/iOS-Location-Spoofer-Web) + * Licensed under CC BY-NC-SA 4.0 + * ⚠️【特别声明】:本项目完全免费开源,严禁以任何形式进行二次售卖、转售、商业收费代搭建! + */ + +import { authOk, jsonResponse, errorResponse, corsHeaders } from '../_utils.js'; + +export async function onRequestDelete(context) { + const { request, env, params } = context; + + if (!authOk(request, env)) { + return errorResponse('unauthorized', 401); + } + + const id = params.id; + + if (env.SPOOFER_DATA) { + let favs = await env.SPOOFER_DATA.get('favorites', { type: 'json' }) || []; + favs = favs.filter(f => f.id !== id); + await env.SPOOFER_DATA.put('favorites', JSON.stringify(favs)); + } + + return jsonResponse({ ok: true }); +} + +export async function onRequestOptions() { + return new Response(null, { + status: 204, + headers: corsHeaders() + }); +} diff --git a/functions/favorites/index.js b/functions/favorites/index.js new file mode 100644 index 0000000..e7a0ed4 --- /dev/null +++ b/functions/favorites/index.js @@ -0,0 +1,69 @@ +/** + * iOS Location Spoofer Web + * + * Copyright (c) 2026 akudamatata (https://github.com/akudamatata/iOS-Location-Spoofer-Web) + * Licensed under CC BY-NC-SA 4.0 + * ⚠️【特别声明】:本项目完全免费开源,严禁以任何形式进行二次售卖、转售、商业收费代搭建! + */ + +import { authOk, jsonResponse, errorResponse, corsHeaders } from '../_utils.js'; + +export async function onRequestGet(context) { + const { request, env } = context; + + if (!authOk(request, env)) { + return errorResponse('unauthorized', 401); + } + + let favs = []; + if (env.SPOOFER_DATA) { + favs = await env.SPOOFER_DATA.get('favorites', { type: 'json' }) || []; + } + + return jsonResponse(favs); +} + +export async function onRequestPost(context) { + const { request, env } = context; + + if (!authOk(request, env)) { + return errorResponse('unauthorized', 401); + } + + try { + const data = await request.json(); + let favs = []; + if (env.SPOOFER_DATA) { + favs = await env.SPOOFER_DATA.get('favorites', { type: 'json' }) || []; + } + + const fav = { + id: Date.now().toString(36), + name: (data.name || '未命名').slice(0, 30), + latitude: data.latitude, + longitude: data.longitude, + altitude: data.altitude ?? null, + horizontalAccuracy: data.horizontalAccuracy ?? null, + verticalAccuracy: data.verticalAccuracy ?? null, + createdAt: new Date().toISOString() + }; + + favs.unshift(fav); + if (favs.length > 100) favs.pop(); + + if (env.SPOOFER_DATA) { + await env.SPOOFER_DATA.put('favorites', JSON.stringify(favs)); + } + + return jsonResponse(fav); + } catch (err) { + return errorResponse('bad json'); + } +} + +export async function onRequestOptions() { + return new Response(null, { + status: 204, + headers: corsHeaders() + }); +} diff --git a/functions/index.js b/functions/index.js new file mode 100644 index 0000000..67e873e --- /dev/null +++ b/functions/index.js @@ -0,0 +1,32 @@ +/** + * iOS Location Spoofer Web + * + * Copyright (c) 2026 akudamatata (https://github.com/akudamatata/iOS-Location-Spoofer-Web) + * Licensed under CC BY-NC-SA 4.0 + * ⚠️【特别声明】:本项目完全免费开源,严禁以任何形式进行二次售卖、转售、商业收费代搭建! + */ + +export async function onRequestGet(context) { + const { request, env } = context; + + // Fetch the static index.html from Cloudflare Pages ASSETS + const response = await env.ASSETS.fetch(request); + + if (!response.ok) { + return response; + } + + const hasToken = Boolean(env.TOKEN); + const amapKey = env.AMAP_KEY || ''; + + const configScript = ``; + + // Use HTMLRewriter to inject the config script just before the closing tag + return new HTMLRewriter() + .on('head', { + element(element) { + element.append(configScript, { html: true }); + } + }) + .transform(response); +} diff --git a/functions/ios-location-spoofer.sgmodule.js b/functions/ios-location-spoofer.sgmodule.js new file mode 100644 index 0000000..a1423e0 --- /dev/null +++ b/functions/ios-location-spoofer.sgmodule.js @@ -0,0 +1,40 @@ +/** + * iOS Location Spoofer Web + * + * Copyright (c) 2026 akudamatata (https://github.com/akudamatata/iOS-Location-Spoofer-Web) + * Licensed under CC BY-NC-SA 4.0 + * ⚠️【特别声明】:本项目完全免费开源,严禁以任何形式进行二次售卖、转售、商业收费代搭建! + */ + +export async function onRequestGet(context) { + const { request, env } = context; + + // Fetch the static asset from Cloudflare Pages + const response = await env.ASSETS.fetch(request); + + if (!response.ok) { + return new Response('Not found', { status: 404 }); + } + + let content = await response.text(); + + const url = new URL(request.url); + const host = request.headers.get('host') || url.host; + const protocol = request.headers.get('x-forwarded-proto') || url.protocol.replace(':', ''); + const tVal = url.searchParams.get('token') || ''; + + content = content.replace(/你的域名/g, host); + content = content.replace(/你的Token/g, tVal); + + if (protocol === 'https') { + content = content.replace(/http:\/\/localhost:8080/g, 'https://' + host); + } + + return new Response(content, { + status: 200, + headers: { + 'Content-Type': 'text/plain; charset=utf-8', + 'Access-Control-Allow-Origin': '*' + } + }); +} diff --git a/functions/loc.json.js b/functions/loc.json.js new file mode 100644 index 0000000..4f2da05 --- /dev/null +++ b/functions/loc.json.js @@ -0,0 +1,31 @@ +/** + * iOS Location Spoofer Web + * + * Copyright (c) 2026 akudamatata (https://github.com/akudamatata/iOS-Location-Spoofer-Web) + * Licensed under CC BY-NC-SA 4.0 + * ⚠️【特别声明】:本项目完全免费开源,严禁以任何形式进行二次售卖、转售、商业收费代搭建! + */ + +import { DEFAULT_LOC, authOk, jsonResponse, errorResponse, corsHeaders } from './_utils.js'; + +export async function onRequestGet(context) { + const { request, env } = context; + + if (!authOk(request, env)) { + return errorResponse('unauthorized', 401); + } + + let loc = DEFAULT_LOC; + if (env.SPOOFER_DATA) { + loc = await env.SPOOFER_DATA.get('loc', { type: 'json' }) || DEFAULT_LOC; + } + + return jsonResponse(loc); +} + +export async function onRequestOptions() { + return new Response(null, { + status: 204, + headers: corsHeaders() + }); +} diff --git a/functions/set.js b/functions/set.js new file mode 100644 index 0000000..fd916b0 --- /dev/null +++ b/functions/set.js @@ -0,0 +1,48 @@ +/** + * iOS Location Spoofer Web + * + * Copyright (c) 2026 akudamatata (https://github.com/akudamatata/iOS-Location-Spoofer-Web) + * Licensed under CC BY-NC-SA 4.0 + * ⚠️【特别声明】:本项目完全免费开源,严禁以任何形式进行二次售卖、转售、商业收费代搭建! + */ + +import { DEFAULT_LOC, authOk, jsonResponse, errorResponse, corsHeaders } from './_utils.js'; + +export async function onRequestPost(context) { + const { request, env } = context; + + if (!authOk(request, env)) { + return errorResponse('unauthorized', 401); + } + + try { + const data = await request.json(); + let current = DEFAULT_LOC; + + if (env.SPOOFER_DATA) { + current = await env.SPOOFER_DATA.get('loc', { type: 'json' }) || DEFAULT_LOC; + } + + const updated = { ...current }; + if (typeof data.latitude === 'number') updated.latitude = data.latitude; + if (typeof data.longitude === 'number') updated.longitude = data.longitude; + if (typeof data.altitude === 'number') updated.altitude = data.altitude; + if (typeof data.horizontalAccuracy === 'number') updated.horizontalAccuracy = data.horizontalAccuracy; + if (typeof data.verticalAccuracy === 'number') updated.verticalAccuracy = data.verticalAccuracy; + + if (env.SPOOFER_DATA) { + await env.SPOOFER_DATA.put('loc', JSON.stringify(updated)); + } + + return jsonResponse(updated); + } catch (err) { + return errorResponse('bad json'); + } +} + +export async function onRequestOptions() { + return new Response(null, { + status: 204, + headers: corsHeaders() + }); +} diff --git a/functions/verify.js b/functions/verify.js new file mode 100644 index 0000000..9057bf4 --- /dev/null +++ b/functions/verify.js @@ -0,0 +1,26 @@ +/** + * iOS Location Spoofer Web + * + * Copyright (c) 2026 akudamatata (https://github.com/akudamatata/iOS-Location-Spoofer-Web) + * Licensed under CC BY-NC-SA 4.0 + * ⚠️【特别声明】:本项目完全免费开源,严禁以任何形式进行二次售卖、转售、商业收费代搭建! + */ + +import { authOk, jsonResponse, errorResponse, corsHeaders } from './_utils.js'; + +export async function onRequestGet(context) { + const { request, env } = context; + + if (!authOk(request, env)) { + return errorResponse('unauthorized', 401); + } + + return jsonResponse({ ok: true }); +} + +export async function onRequestOptions() { + return new Response(null, { + status: 204, + headers: corsHeaders() + }); +} diff --git a/location-spoofer.js b/location-spoofer.js new file mode 100644 index 0000000..de09a10 --- /dev/null +++ b/location-spoofer.js @@ -0,0 +1,922 @@ +/** + * iOS Location Spoofer Web + * + * Copyright (c) 2026 akudamatata (https://github.com/akudamatata/iOS-Location-Spoofer-Web) + * Licensed under CC BY-NC-SA 4.0 + * + * ⚠️【特别声明】:本项目完全免费开源,严禁以任何形式进行二次售卖、转售、商业收费代搭建、打包牟利等行为! + * 若您是通过付费渠道获取本项目的,请立即申请退款并举报不良商家! + * + * 【架构说明】: + * 本脚本为 iOS-Location-Spoofer-Web 项目的核心客户端代理改写模块,专为 Shadowrocket(小火箭)环境设计。 + * 本实现采用自主重构的流式 Protobuf 编解码引擎与纯 JS 64 位整数运算(零 BigInt 依赖), + * 原生兼容 iOS 12+ 至 iOS 27+ 各版本系统。 + * + * 核心流程: + * 1. 拦截 Apple /clls/wloc 定位服务响应; + * 2. 识别封包容器(ARPC 封包 / Marker 封包 / Synthetic 封包 / Bare Protobuf / 滑窗扫描兜底); + * 3. 采用「WLOC 最小改写(Minimal Rewrite)」策略,仅修改经纬度与水平精度,其余字段原值透传; + * 4. 重新组装并以原始或标准容器格式回包给系统。 + */ +(function () { + "use strict"; + + /* ───────────────────────────────────────────────────────────── + 1. 配置与默认参数 (Configuration & Constants) + ───────────────────────────────────────────────────────────── */ + var CONFIG_DEFAULTS = { + enabled: true, + mode: "response", + latitude: 39.90872, + longitude: 116.39748, + horizontalAccuracy: 39, + verticalAccuracy: 1000, + altitude: 44, + failOpen: true, + debug: false, + rawLimit: 0 + }; + + // Apple 定位私有协议特征常数 + var APPLE_SYNTHETIC_PREFIX = new Uint8Array([0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00]); + var APPLE_MARKER_SIGNATURE = new Uint8Array([0x00, 0x00, 0x00, 0x01, 0x00, 0x00]); + var CELL_RESPONSE_TAGS = { 22: true, 24: true }; + + /* ───────────────────────────────────────────────────────────── + 2. 基础字节流与诊断工具 (Byte Stream & Diagnostic Utilities) + ───────────────────────────────────────────────────────────── */ + var ByteUtils = { + fromArray: function (arr) { + return new Uint8Array(arr); + }, + + concat: function (chunks) { + var total = 0; + var i; + for (i = 0; i < chunks.length; i += 1) { + total += chunks[i].length; + } + var out = new Uint8Array(total); + var offset = 0; + for (i = 0; i < chunks.length; i += 1) { + out.set(chunks[i], offset); + offset += chunks[i].length; + } + return out; + }, + + hasPrefix: function (source, prefix) { + if (!source || !prefix || source.length < prefix.length) { + return false; + } + for (var i = 0; i < prefix.length; i += 1) { + if (source[i] !== prefix[i]) { + return false; + } + } + return true; + }, + + findSequence: function (source, sequence) { + if (!source || !sequence || sequence.length === 0 || source.length < sequence.length) { + return -1; + } + var maxIdx = source.length - sequence.length; + for (var i = 0; i <= maxIdx; i += 1) { + var match = true; + for (var j = 0; j < sequence.length; j += 1) { + if (source[i + j] !== sequence[j]) { + match = false; + break; + } + } + if (match) { + return i; + } + } + return -1; + }, + + toHex: function (bytes, limit) { + if (!bytes) return ""; + var max = Math.min(bytes.length, limit || 16); + var hex = []; + for (var i = 0; i < max; i += 1) { + hex.push(("0" + bytes[i].toString(16)).slice(-2)); + } + return hex.join(""); + }, + + toBinaryString: function (bytes) { + if (!bytes) return ""; + var chunkSize = 0x8000; + var parts = []; + for (var i = 0; i < bytes.length; i += chunkSize) { + var chunk = bytes.subarray(i, i + chunkSize); + parts.push(String.fromCharCode.apply(null, Array.prototype.slice.call(chunk))); + } + return parts.join(""); + }, + + fromBinaryString: function (str) { + if (!str) return new Uint8Array(0); + var out = new Uint8Array(str.length); + for (var i = 0; i < str.length; i += 1) { + out[i] = str.charCodeAt(i) & 0xff; + } + return out; + }, + + toBase64: function (bytes) { + var alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + var out = ""; + for (var i = 0; i < bytes.length; i += 3) { + var b0 = bytes[i]; + var b1 = i + 1 < bytes.length ? bytes[i + 1] : 0; + var b2 = i + 2 < bytes.length ? bytes[i + 2] : 0; + var triplet = (b0 << 16) | (b1 << 8) | b2; + out += alphabet[(triplet >> 18) & 0x3f]; + out += alphabet[(triplet >> 12) & 0x3f]; + out += i + 1 < bytes.length ? alphabet[(triplet >> 6) & 0x3f] : "="; + out += i + 2 < bytes.length ? alphabet[triplet & 0x3f] : "="; + } + return out; + }, + + toUint8Array: function (input) { + if (input == null) return null; + if (input instanceof Uint8Array) return input; + if (typeof ArrayBuffer !== "undefined" && input instanceof ArrayBuffer) return new Uint8Array(input); + if (typeof input === "string") return ByteUtils.fromBinaryString(input); + if (typeof input === "object" && typeof input.length === "number") return new Uint8Array(input); + if (typeof input === "object" && input.bytes && typeof input.bytes.length === "number") return new Uint8Array(input.bytes); + if (typeof input === "object" && input.data && typeof input.data.length === "number") return new Uint8Array(input.data); + return null; + } + }; + + /* ───────────────────────────────────────────────────────────── + 3. 纯 JS 64 位 Varint 编解码器(Pure JS 64-bit Varint) + 零 BigInt 依赖,原生兼容 iOS 12+ JavaScriptCore + ───────────────────────────────────────────────────────────── */ + var Varint64 = { + UINT32_MOD: 4294967296, + MAX_SAFE: 9007199254740991, + + fromUnsigned: function (val) { + var num = Number(val); + if (!Number.isFinite(num) || num < 0 || num > Varint64.MAX_SAFE) { + throw new Error("Invalid unsigned int64 value: " + val); + } + return { + low: num >>> 0, + high: Math.floor(num / Varint64.UINT32_MOD) >>> 0 + }; + }, + + fromSigned: function (val) { + var num = Math.trunc(Number(val)); + if (!Number.isFinite(num) || Math.abs(num) > Varint64.MAX_SAFE) { + throw new Error("Invalid signed int64 value: " + val); + } + if (num >= 0) { + return Varint64.fromUnsigned(num); + } + var pos = Varint64.fromUnsigned(-num); + var low = (~pos.low + 1) >>> 0; + var carry = low === 0 ? 1 : 0; + var high = (~pos.high + carry) >>> 0; + return { low: low, high: high }; + }, + + toSignedNumber: function (words) { + var low = words.low >>> 0; + var high = words.high >>> 0; + if ((high & 0x80000000) === 0) { + return (high >>> 0) * Varint64.UINT32_MOD + (low >>> 0); + } + var magLow = (~low + 1) >>> 0; + var carry = magLow === 0 ? 1 : 0; + var magHigh = (~high + carry) >>> 0; + var mag = magHigh * Varint64.UINT32_MOD + magLow; + return -mag; + }, + + encodeWords: function (words) { + var low = words.low >>> 0; + var high = words.high >>> 0; + var out = []; + while (high !== 0 || low >= 0x80) { + out.push((low & 0x7f) | 0x80); + low = ((low >>> 7) | (high << 25)) >>> 0; + high = high >>> 7; + } + out.push(low & 0x7f); + return ByteUtils.fromArray(out); + }, + + encodeUnsigned: function (val) { + return Varint64.encodeWords(Varint64.fromUnsigned(val)); + }, + + encodeSigned: function (val) { + return Varint64.encodeWords(Varint64.fromSigned(val)); + }, + + decode: function (bytes, offset) { + var low = 0; + var high = 0; + var shift = 0; + var current = offset || 0; + var count = 0; + + while (current < bytes.length && count < 10) { + var b = bytes[current]; + var payload = b & 0x7f; + current += 1; + count += 1; + + if (shift < 32) { + low = (low | ((payload << shift) >>> 0)) >>> 0; + if (shift > 25) { + high = (high | (payload >>> (32 - shift))) >>> 0; + } + } else { + high = (high | ((payload << (shift - 32)) >>> 0)) >>> 0; + } + + if ((b & 0x80) === 0) { + return { low: low, high: high, offset: current }; + } + shift += 7; + } + throw new Error("Truncated or malformed varint"); + } + }; + + /* ───────────────────────────────────────────────────────────── + 4. Protobuf 协议处理引擎 (Protobuf Streaming Engine) + ───────────────────────────────────────────────────────────── */ + var ProtobufEngine = { + readFields: function (bytes) { + var fields = []; + var offset = 0; + + while (offset < bytes.length) { + var fieldStart = offset; + var tagVarint = Varint64.decode(bytes, offset); + offset = tagVarint.offset; + + var tagVal = tagVarint.low; + var fieldNumber = tagVal >>> 3; + var wireType = tagVal & 0x07; + + if (fieldNumber <= 0) { + throw new Error("Invalid protobuf field number: " + fieldNumber); + } + + if (wireType === 0) { + // Varint + var valVarint = Varint64.decode(bytes, offset); + var rawBytes = bytes.subarray(fieldStart, valVarint.offset); + var valBytes = bytes.subarray(offset, valVarint.offset); + fields.push({ + fieldNumber: fieldNumber, + wireType: wireType, + raw: rawBytes, + valueBytes: valBytes, + varint: valVarint, + int64Value: Varint64.toSignedNumber(valVarint) + }); + offset = valVarint.offset; + } else if (wireType === 2) { + // Length-delimited (submessage, bytes, string) + var lenVarint = Varint64.decode(bytes, offset); + offset = lenVarint.offset; + var length = lenVarint.low; + if (offset + length > bytes.length) { + throw new Error("Protobuf length-delimited exceeds buffer bounds"); + } + var valueBytes = bytes.subarray(offset, offset + length); + offset += length; + fields.push({ + fieldNumber: fieldNumber, + wireType: wireType, + raw: bytes.subarray(fieldStart, offset), + valueBytes: valueBytes + }); + } else if (wireType === 5) { + // 32-bit fixed + if (offset + 4 > bytes.length) throw new Error("Protobuf fixed32 truncated"); + offset += 4; + fields.push({ + fieldNumber: fieldNumber, + wireType: wireType, + raw: bytes.subarray(fieldStart, offset) + }); + } else if (wireType === 1) { + // 64-bit fixed + if (offset + 8 > bytes.length) throw new Error("Protobuf fixed64 truncated"); + offset += 8; + fields.push({ + fieldNumber: fieldNumber, + wireType: wireType, + raw: bytes.subarray(fieldStart, offset) + }); + } else { + throw new Error("Unsupported wire type: " + wireType); + } + } + return fields; + }, + + safeReadFields: function (bytes) { + try { + if (!bytes || bytes.length === 0) return null; + var res = ProtobufEngine.readFields(bytes); + return res.length > 0 ? res : null; + } catch (err) { + return null; + } + }, + + makeVarintField: function (fieldNumber, signedOrUnsignedVal) { + var tagBytes = Varint64.encodeUnsigned((fieldNumber << 3) | 0); + var valBytes = typeof signedOrUnsignedVal === "number" && signedOrUnsignedVal < 0 + ? Varint64.encodeSigned(signedOrUnsignedVal) + : Varint64.encodeSigned(signedOrUnsignedVal); + return ByteUtils.concat([tagBytes, valBytes]); + }, + + makeLengthDelimitedField: function (fieldNumber, payloadBytes) { + var tagBytes = Varint64.encodeUnsigned((fieldNumber << 3) | 2); + var lenBytes = Varint64.encodeUnsigned(payloadBytes.length); + return ByteUtils.concat([tagBytes, lenBytes, payloadBytes]); + } + }; + + /* ───────────────────────────────────────────────────────────── + 5. Apple ARPC 封包处理 (Apple ARPC Framing Codec) + ───────────────────────────────────────────────────────────── */ + var ArpcCodec = { + readUInt16BE: function (bytes, offset) { + if (offset + 2 > bytes.length) throw new Error("UInt16 out of bounds"); + return (bytes[offset] << 8) | bytes[offset + 1]; + }, + + readUInt32BE: function (bytes, offset) { + if (offset + 4 > bytes.length) throw new Error("UInt32 out of bounds"); + return ( + (bytes[offset] * 0x1000000) + + ((bytes[offset + 1] << 16) | (bytes[offset + 2] << 8) | bytes[offset + 3]) + ) >>> 0; + }, + + writeUInt16BE: function (val) { + return ByteUtils.fromArray([(val >> 8) & 0xff, val & 0xff]); + }, + + writeUInt32BE: function (val) { + return ByteUtils.fromArray([ + (val >>> 24) & 0xff, + (val >>> 16) & 0xff, + (val >>> 8) & 0xff, + val & 0xff + ]); + }, + + readPascalString: function (bytes, state) { + var len = ArpcCodec.readUInt16BE(bytes, state.offset); + state.offset += 2; + if (state.offset + len > bytes.length) throw new Error("ARPC string out of bounds"); + var chars = []; + for (var i = 0; i < len; i += 1) { + chars.push(String.fromCharCode(bytes[state.offset + i])); + } + state.offset += len; + return chars.join(""); + }, + + writePascalString: function (str) { + var bytes = new Uint8Array(str.length); + for (var i = 0; i < str.length; i += 1) { + bytes[i] = str.charCodeAt(i) & 0x7f; + } + return ByteUtils.concat([ArpcCodec.writeUInt16BE(bytes.length), bytes]); + }, + + parse: function (bytes) { + var state = { offset: 0 }; + var version = ArpcCodec.readUInt16BE(bytes, state.offset); + state.offset += 2; + var locale = ArpcCodec.readPascalString(bytes, state); + var appIdentifier = ArpcCodec.readPascalString(bytes, state); + var osVersion = ArpcCodec.readPascalString(bytes, state); + var functionId = ArpcCodec.readUInt32BE(bytes, state.offset); + state.offset += 4; + var payloadLength = ArpcCodec.readUInt32BE(bytes, state.offset); + state.offset += 4; + + if (state.offset + payloadLength > bytes.length) { + throw new Error("ARPC payload length out of bounds"); + } + + return { + version: version, + locale: locale, + appIdentifier: appIdentifier, + osVersion: osVersion, + functionId: functionId, + payload: bytes.slice(state.offset, state.offset + payloadLength) + }; + }, + + serialize: function (arpc) { + return ByteUtils.concat([ + ArpcCodec.writeUInt16BE(arpc.version), + ArpcCodec.writePascalString(arpc.locale), + ArpcCodec.writePascalString(arpc.appIdentifier), + ArpcCodec.writePascalString(arpc.osVersion), + ArpcCodec.writeUInt32BE(arpc.functionId), + ArpcCodec.writeUInt32BE(arpc.payload.length), + arpc.payload + ]); + } + }; + + /* ───────────────────────────────────────────────────────────── + 6. WLOC 定位篡改核心 (WLOC Minimal Spoofer & Raw Scanner) + ───────────────────────────────────────────────────────────── */ + function coordToInt(deg) { + return Math.round(Number(deg) * 100000000); + } + + // 最小改写 Location 子消息:仅替换已存在的 纬度(1)/经度(2)/水平精度(3),原值透传其余所有字段 + function patchLocationRecord(locationBytes, config) { + if (!locationBytes || locationBytes.length === 0) return locationBytes; + var fields = ProtobufEngine.readFields(locationBytes); + var hasLat = false; + var hasLon = false; + var i; + + for (i = 0; i < fields.length; i += 1) { + if (fields[i].fieldNumber === 1 && fields[i].wireType === 0) hasLat = true; + if (fields[i].fieldNumber === 2 && fields[i].wireType === 0) hasLon = true; + } + + // 若无经纬度结构,原样放行,避免破坏响应结构 + if (!hasLat || !hasLon) { + return locationBytes; + } + + var parts = []; + for (i = 0; i < fields.length; i += 1) { + var f = fields[i]; + if (f.fieldNumber === 1 && f.wireType === 0) { + parts.push(ProtobufEngine.makeVarintField(1, coordToInt(config.latitude))); + } else if (f.fieldNumber === 2 && f.wireType === 0) { + parts.push(ProtobufEngine.makeVarintField(2, coordToInt(config.longitude))); + } else if (f.fieldNumber === 3 && f.wireType === 0) { + parts.push(ProtobufEngine.makeVarintField(3, config.horizontalAccuracy)); + } else { + parts.push(f.raw); + } + } + return ByteUtils.concat(parts); + } + + function patchWifiEntity(wifiBytes, config) { + var fields = ProtobufEngine.readFields(wifiBytes); + var parts = []; + for (var i = 0; i < fields.length; i += 1) { + var f = fields[i]; + if (f.fieldNumber === 2 && f.wireType === 2) { + parts.push(ProtobufEngine.makeLengthDelimitedField(2, patchLocationRecord(f.valueBytes, config))); + } else { + parts.push(f.raw); + } + } + return ByteUtils.concat(parts); + } + + function patchCellEntity(cellBytes, config) { + var fields = ProtobufEngine.readFields(cellBytes); + var parts = []; + for (var i = 0; i < fields.length; i += 1) { + var f = fields[i]; + if (f.fieldNumber === 5 && f.wireType === 2) { + parts.push(ProtobufEngine.makeLengthDelimitedField(5, patchLocationRecord(f.valueBytes, config))); + } else { + parts.push(f.raw); + } + } + return ByteUtils.concat(parts); + } + + function patchWlocPayload(payloadBytes, config) { + var fields = ProtobufEngine.readFields(payloadBytes); + var parts = []; + var wifiCount = 0; + var cellCount = 0; + + for (var i = 0; i < fields.length; i += 1) { + var f = fields[i]; + if (f.fieldNumber === 2 && f.wireType === 2) { + parts.push(ProtobufEngine.makeLengthDelimitedField(2, patchWifiEntity(f.valueBytes, config))); + wifiCount += 1; + } else if (CELL_RESPONSE_TAGS[f.fieldNumber] && f.wireType === 2) { + parts.push(ProtobufEngine.makeLengthDelimitedField(f.fieldNumber, patchCellEntity(f.valueBytes, config))); + cellCount += 1; + } else { + // 关键:其余根级字段(包括 3, 4, 33 等)全量原样透传,不丢弃! + parts.push(f.raw); + } + } + + return { + payload: ByteUtils.concat(parts), + wifiCount: wifiCount, + cellCount: cellCount + }; + } + + function buildSyntheticResponse(payload, prefix) { + var pref = prefix || APPLE_SYNTHETIC_PREFIX; + return ByteUtils.concat([pref, ArpcCodec.writeUInt16BE(payload.length), payload]); + } + + function looksLikeWlocPayload(bytes) { + if (!bytes || bytes.length === 0) return false; + var tag = bytes[0]; + var fieldNumber = tag >> 3; + var wireType = tag & 0x07; + return fieldNumber > 0 && (wireType === 0 || wireType === 2); + } + + // 滑窗特征扫描兜底:当遭遇未知前缀头部时,自动在 0~256 字节内滑窗定位 WLOC protobuf + function scanPatchRawBuffer(responseBytes, config) { + if (!responseBytes || responseBytes.length < 8) { + throw new Error("Response buffer too short for raw scan"); + } + + var offsets = []; + var i; + var frameLimit = Math.min(96, Math.max(0, responseBytes.length - 10)); + for (i = 0; i <= frameLimit; i += 2) offsets.push(i); + var rawLimit = Math.min(256, Math.max(0, responseBytes.length - 4)); + for (i = 0; i <= rawLimit; i += 1) { + if (offsets.indexOf(i) < 0) offsets.push(i); + } + + for (i = 0; i < offsets.length; i += 1) { + var offset = offsets[i]; + try { + var slice = responseBytes.subarray(offset); + if (!looksLikeWlocPayload(slice)) continue; + var patched = patchWlocPayload(slice, config); + if (patched.wifiCount > 0 || patched.cellCount > 0) { + return { + response: buildSyntheticResponse(patched.payload), + payload: patched.payload, + wifiCount: patched.wifiCount, + cellCount: patched.cellCount, + kind: "raw", + offset: offset + }; + } + } catch (e) { + // Continue scanning + } + } + throw new Error("Raw scan found no valid patchable WLOC payload"); + } + + function extractEnvelope(responseBytes) { + if (!responseBytes || responseBytes.length < 2) { + throw new Error("Response body is empty or too short"); + } + + // 1. Prefixed synthetic format + if (responseBytes.length >= 10 && responseBytes[0] === 0x00 && responseBytes[1] === 0x01 && responseBytes[6] === 0x00 && responseBytes[7] === 0x00) { + var payloadLen = ArpcCodec.readUInt16BE(responseBytes, 8); + if (payloadLen > 0 && 10 + payloadLen <= responseBytes.length) { + var payloadCandidate = responseBytes.subarray(10, 10 + payloadLen); + if (ProtobufEngine.safeReadFields(payloadCandidate) !== null) { + return { + kind: "synthetic", + payload: payloadCandidate, + prefix: responseBytes.subarray(0, 8), + suffix: responseBytes.subarray(10 + payloadLen) + }; + } + } + } + + // 2. Structured ARPC format + try { + var arpc = ArpcCodec.parse(responseBytes); + if (arpc.payload.length > 0 && ProtobufEngine.safeReadFields(arpc.payload) !== null) { + return { + kind: "arpc", + payload: arpc.payload, + arpc: arpc + }; + } + } catch (e) { + // Not standard ARPC + } + + // 3. Marker signature format + var markerIdx = ByteUtils.findSequence(responseBytes, APPLE_MARKER_SIGNATURE); + if (markerIdx >= 0) { + var lenOffset = markerIdx + APPLE_MARKER_SIGNATURE.length; + if (lenOffset + 2 <= responseBytes.length) { + var markerLen = ArpcCodec.readUInt16BE(responseBytes, lenOffset); + var pOffset = lenOffset + 2; + if (markerLen > 0 && pOffset + markerLen <= responseBytes.length) { + var markerPayload = responseBytes.subarray(pOffset, pOffset + markerLen); + if (ProtobufEngine.safeReadFields(markerPayload) !== null) { + return { + kind: "marker", + payload: markerPayload, + prefix: responseBytes.subarray(0, markerIdx), + markerAndLen: responseBytes.subarray(markerIdx, pOffset), + suffix: responseBytes.subarray(pOffset + markerLen) + }; + } + } + } + } + + // 4. Bare protobuf + if (looksLikeWlocPayload(responseBytes) && ProtobufEngine.safeReadFields(responseBytes) !== null) { + return { + kind: "bare", + payload: responseBytes + }; + } + + return null; + } + + function spoofAppleResponse(responseBytes, rawConfig) { + var config = normalizeConfig(rawConfig); + var extraction = null; + var strictError = null; + + try { + extraction = extractEnvelope(responseBytes); + } catch (err) { + strictError = err; + } + + if (extraction) { + var patched = patchWlocPayload(extraction.payload, config); + if (patched.wifiCount > 0 || patched.cellCount > 0) { + var finalResponse; + if (extraction.kind === "arpc") { + finalResponse = ArpcCodec.serialize({ + version: extraction.arpc.version, + locale: extraction.arpc.locale, + appIdentifier: extraction.arpc.appIdentifier, + osVersion: extraction.arpc.osVersion, + functionId: extraction.arpc.functionId, + payload: patched.payload + }); + } else if (extraction.kind === "marker") { + var newLen = ArpcCodec.writeUInt16BE(patched.payload.length); + finalResponse = ByteUtils.concat([ + extraction.prefix, + extraction.markerAndLen.subarray(0, APPLE_MARKER_SIGNATURE.length), + newLen, + patched.payload, + extraction.suffix + ]); + } else { + finalResponse = buildSyntheticResponse(patched.payload, extraction.prefix); + } + + return { + response: finalResponse, + payload: patched.payload, + wifiCount: patched.wifiCount, + cellCount: patched.cellCount, + kind: extraction.kind, + prefix: extraction.prefix ? ByteUtils.toHex(extraction.prefix, 8) : "" + }; + } + strictError = new Error("No patchable location submessages via " + extraction.kind); + } + + // 触发滑窗扫描兜底 + var rawScan = scanPatchRawBuffer(responseBytes, config); + return { + response: rawScan.response, + payload: rawScan.payload, + wifiCount: rawScan.wifiCount, + cellCount: rawScan.cellCount, + kind: rawScan.kind, + offset: rawScan.offset, + strictError: strictError ? strictError.message : null + }; + } + + /* ───────────────────────────────────────────────────────────── + 7. 参数解析与配置归一化 (Arguments & Config Normalization) + ───────────────────────────────────────────────────────────── */ + function normalizeConfig(input) { + var cfg = {}; + input = input || {}; + for (var k in CONFIG_DEFAULTS) { + if (Object.prototype.hasOwnProperty.call(CONFIG_DEFAULTS, k)) { + cfg[k] = CONFIG_DEFAULTS[k]; + } + } + for (var key in input) { + if (Object.prototype.hasOwnProperty.call(input, key) && input[key] !== undefined) { + cfg[key] = input[key]; + } + } + cfg.latitude = Number(cfg.latitude); + cfg.longitude = Number(cfg.longitude); + cfg.horizontalAccuracy = Number(cfg.horizontalAccuracy) || 39; + cfg.verticalAccuracy = Number(cfg.verticalAccuracy) || 1000; + cfg.altitude = Number(cfg.altitude) || 44; + cfg.debug = cfg.debug === true || cfg.debug === "true"; + cfg.failOpen = cfg.failOpen !== false && cfg.failOpen !== "false"; + return cfg; + } + + function parseArgumentString(argStr) { + var out = {}; + if (!argStr || typeof argStr !== "string") return out; + + var configUrlIdx = argStr.indexOf("configUrl="); + if (configUrlIdx >= 0) { + var after = argStr.slice(configUrlIdx + 10); + var endIdx = after.search(/[&,\s]/); + if (endIdx < 0) { + out.configUrl = after; + argStr = argStr.slice(0, configUrlIdx); + } else { + out.configUrl = after.slice(0, endIdx); + argStr = argStr.slice(0, configUrlIdx) + after.slice(endIdx); + } + } + + var tokens = argStr.split(/[&,]/); + for (var i = 0; i < tokens.length; i += 1) { + var token = tokens[i].trim(); + if (!token) continue; + var eq = token.indexOf("="); + if (eq > 0) { + var key = token.slice(0, eq).trim(); + var val = token.slice(eq + 1).trim(); + out[key] = val; + } + } + return out; + } + + /* ───────────────────────────────────────────────────────────── + 8. Shadowrocket 运行时接入 (Shadowrocket Runtime Integration) + ───────────────────────────────────────────────────────────── */ + function fetchRemoteConfig(url, callback) { + if (!url || typeof $httpClient === "undefined") { + callback(null); + return; + } + $httpClient.get({ url: url, timeout: 5 }, function (err, resp, data) { + if (err || !data || (resp && resp.status >= 400)) { + callback(null); + return; + } + try { + var json = JSON.parse(data); + callback(json); + } catch (e) { + callback(null); + } + }); + } + + function runShadowrocket() { + var hasResponse = typeof $response !== "undefined" && $response != null; + var hasRequest = typeof $request !== "undefined" && $request != null; + + if (!hasResponse && !hasRequest) { + return; + } + + var scriptArgs = typeof $argument === "string" ? parseArgumentString($argument) : {}; + var config = normalizeConfig(scriptArgs); + + function passThrough() { + $done({}); + } + + function completeResponse(bodyBytes, wifiCount, cellCount) { + var headers = ($response && $response.headers) ? $response.headers : {}; + delete headers["Content-Encoding"]; + delete headers["content-encoding"]; + headers["Content-Length"] = String(bodyBytes.length); + headers["X-Location-Spoofer"] = "active"; + headers["X-Location-Spoofer-Wifi"] = String(wifiCount); + headers["X-Location-Spoofer-Cell"] = String(cellCount); + + $done({ + response: { + status: 200, + headers: headers, + body: bodyBytes + } + }); + } + + function processRewrite(activeConfig) { + try { + var rawBody = ByteUtils.toUint8Array(($response && $response.body != null) ? $response.body : ($response && $response.bodyBytes)); + if (!rawBody || rawBody.length < 2) { + passThrough(); + return; + } + + var result = spoofAppleResponse(rawBody, activeConfig); + if (activeConfig.debug) { + console.log("[Location Spoofer] Patched " + result.wifiCount + " Wi-Fi, " + result.cellCount + " Cell. Format: " + result.kind); + } + completeResponse(result.response, result.wifiCount, result.cellCount); + } catch (err) { + if (activeConfig.debug) { + console.log("[Location Spoofer] Failed: " + err.message); + } + if (activeConfig.failOpen) { + passThrough(); + } else { + $done({ + response: { + status: "HTTP/1.1 500 Internal Server Error", + headers: { "Content-Type": "text/plain" }, + body: "Location spoofing failed: " + err.message + } + }); + } + } + } + + if (config.configUrl) { + fetchRemoteConfig(config.configUrl, function (remoteData) { + if (remoteData) { + if (remoteData.latitude != null) config.latitude = Number(remoteData.latitude); + if (remoteData.longitude != null) config.longitude = Number(remoteData.longitude); + if (remoteData.horizontalAccuracy != null) config.horizontalAccuracy = Number(remoteData.horizontalAccuracy); + if (remoteData.verticalAccuracy != null) config.verticalAccuracy = Number(remoteData.verticalAccuracy); + if (remoteData.altitude != null) config.altitude = Number(remoteData.altitude); + } + processRewrite(config); + }); + } else { + processRewrite(config); + } + } + + /* ───────────────────────────────────────────────────────────── + 9. 模块导出与入口 (Module Export & Entry Point) + ───────────────────────────────────────────────────────────── */ + var api = { + CONFIG_DEFAULTS: CONFIG_DEFAULTS, + APPLE_SYNTHETIC_PREFIX: APPLE_SYNTHETIC_PREFIX, + APPLE_MARKER_SIGNATURE: APPLE_MARKER_SIGNATURE, + ByteUtils: ByteUtils, + Varint64: Varint64, + ProtobufEngine: ProtobufEngine, + ArpcCodec: ArpcCodec, + coordToInt: coordToInt, + normalizeConfig: normalizeConfig, + parseArgumentString: parseArgumentString, + patchLocationRecord: patchLocationRecord, + patchWifiEntity: patchWifiEntity, + patchCellEntity: patchCellEntity, + patchWlocPayload: patchWlocPayload, + buildSyntheticResponse: buildSyntheticResponse, + scanPatchRawBuffer: scanPatchRawBuffer, + extractEnvelope: extractEnvelope, + spoofAppleResponse: spoofAppleResponse, + uint64ToSignedNumber: Varint64.toSignedNumber, + // 兼容测试套件别名 + concatBytes: ByteUtils.concat, + decodeVarint: Varint64.decode, + encodeVarintUnsigned: Varint64.encodeUnsigned, + encodeVarintSignedInt64: Varint64.encodeSigned, + makeVarintField: ProtobufEngine.makeVarintField, + makeLengthDelimitedField: ProtobufEngine.makeLengthDelimitedField, + parseFields: ProtobufEngine.readFields, + buildAppleWLocResponse: buildSyntheticResponse + }; + + if (typeof module !== "undefined" && module.exports) { + module.exports = api; + } else { + runShadowrocket(); + } +}()); diff --git a/package.json b/package.json new file mode 100644 index 0000000..eb91ead --- /dev/null +++ b/package.json @@ -0,0 +1,14 @@ +{ + "name": "ios-location-spoofer-web-selfhosted", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "start": "node server.mjs", + "check": "node --check server.mjs" + }, + "engines": { + "node": ">=22" + } +} + diff --git a/public/icon-512.png b/public/icon-512.png new file mode 100644 index 0000000..17ddd54 Binary files /dev/null and b/public/icon-512.png differ diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..19fb27d --- /dev/null +++ b/public/index.html @@ -0,0 +1,1905 @@ + + + + + + + + + + + +GPS Spoofer + + + + + + + + + + + +
+ + +
+ + +
+
+ + + + + + + + + + + + +
+ + +
+
+
---, ---
+
海拔 — m
+
+ +
+ + + +
+ + +
+ + + + + + + + + +
+
+ 高德地图✓ +
+
+ 高德卫星✓ +
+
+ 国际地图✓ +
+
+ 国际卫星✓ +
+
+ + +
+ + + +
+ + + + + +
+
+

Shadowrocket 配置

+ +
+
+
+
方法 A:一键导入小火箭模块
+
最推荐的方式。点击下方按钮即可唤醒手机上的 Shadowrocket 自动导入到【模块】列表中。
+ +
+
+
方法 B:手动复制模块链接
+
如果在微信或第三方浏览器无法唤醒,请复制下方完整的模块链接,前往 Shadowrocket 的【配置】页面 -> 点击进入【模块】 -> 点击右上角【+】号 -> 粘贴此 URL 下载即可。
+
...
+ +
+
+
方法 C:一键复制 argument 参数
+
如果您已导入了模块,只需将下方为您拼装好的 argument 覆盖原模块参数即可。
+
...
+ +
+
+
+
+ + 深色模式 +
+
+
+
+
+
+ + +
+
+

收藏夹

+ +
+
+
+ + 暂无收藏
锁定位置后点「收藏」即可保存 +
+
+
+ + +
+
+ + +
+
+ 海拔 + + m +
+
+ 水平精度 + + m +
+
+ 垂直精度 + + m +
+
+ + +
+
+ + 点击地图选择位置 +
+ +
+
+ + + + + +
+ + + + + + diff --git a/public/ios-location-spoofer.sgmodule b/public/ios-location-spoofer.sgmodule new file mode 100644 index 0000000..9278699 --- /dev/null +++ b/public/ios-location-spoofer.sgmodule @@ -0,0 +1,10 @@ +#!name=iOS Location Spoofer (Self-Hosted) +#!desc=拦截 Apple 定位服务器回应的 GPS 坐标,替换成自定义位置。【免费开源/严禁倒卖】唯一开源仓库: https://github.com/akudamatata/iOS-Location-Spoofer-Web +#!homepage=https://github.com/akudamatata/iOS-Location-Spoofer-Web +#!author=akudamatata + +[Script] +iOS Location Spoofer = type=http-response,pattern=^https?:\/\/(?:gs-loc(?:-cn)?\.apple\.com|gsp-ssl\.ls\.apple\.com|bluedot\.is\.autonavi\.com(?:\.gds\.alibabadns\.com)?)\/clls\/wloc(?:\?.*)?$,requires-body=1,binary-body-mode=1,max-size=0,timeout=30,script-path=https://你的域名/location-spoofer.js,argument=mode=response&configUrl=https://你的域名/loc.json?token=你的Token&latitude=39.90872&longitude=116.39748&horizontalAccuracy=39&verticalAccuracy=1000&altitude=44&debug=false + +[MITM] +hostname = %APPEND% gs-loc.apple.com, gs-loc-cn.apple.com, gsp-ssl.ls.apple.com, bluedot.is.autonavi.com, bluedot.is.autonavi.com.gds.alibabadns.com diff --git a/public/location-spoofer.js b/public/location-spoofer.js new file mode 100644 index 0000000..de09a10 --- /dev/null +++ b/public/location-spoofer.js @@ -0,0 +1,922 @@ +/** + * iOS Location Spoofer Web + * + * Copyright (c) 2026 akudamatata (https://github.com/akudamatata/iOS-Location-Spoofer-Web) + * Licensed under CC BY-NC-SA 4.0 + * + * ⚠️【特别声明】:本项目完全免费开源,严禁以任何形式进行二次售卖、转售、商业收费代搭建、打包牟利等行为! + * 若您是通过付费渠道获取本项目的,请立即申请退款并举报不良商家! + * + * 【架构说明】: + * 本脚本为 iOS-Location-Spoofer-Web 项目的核心客户端代理改写模块,专为 Shadowrocket(小火箭)环境设计。 + * 本实现采用自主重构的流式 Protobuf 编解码引擎与纯 JS 64 位整数运算(零 BigInt 依赖), + * 原生兼容 iOS 12+ 至 iOS 27+ 各版本系统。 + * + * 核心流程: + * 1. 拦截 Apple /clls/wloc 定位服务响应; + * 2. 识别封包容器(ARPC 封包 / Marker 封包 / Synthetic 封包 / Bare Protobuf / 滑窗扫描兜底); + * 3. 采用「WLOC 最小改写(Minimal Rewrite)」策略,仅修改经纬度与水平精度,其余字段原值透传; + * 4. 重新组装并以原始或标准容器格式回包给系统。 + */ +(function () { + "use strict"; + + /* ───────────────────────────────────────────────────────────── + 1. 配置与默认参数 (Configuration & Constants) + ───────────────────────────────────────────────────────────── */ + var CONFIG_DEFAULTS = { + enabled: true, + mode: "response", + latitude: 39.90872, + longitude: 116.39748, + horizontalAccuracy: 39, + verticalAccuracy: 1000, + altitude: 44, + failOpen: true, + debug: false, + rawLimit: 0 + }; + + // Apple 定位私有协议特征常数 + var APPLE_SYNTHETIC_PREFIX = new Uint8Array([0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00]); + var APPLE_MARKER_SIGNATURE = new Uint8Array([0x00, 0x00, 0x00, 0x01, 0x00, 0x00]); + var CELL_RESPONSE_TAGS = { 22: true, 24: true }; + + /* ───────────────────────────────────────────────────────────── + 2. 基础字节流与诊断工具 (Byte Stream & Diagnostic Utilities) + ───────────────────────────────────────────────────────────── */ + var ByteUtils = { + fromArray: function (arr) { + return new Uint8Array(arr); + }, + + concat: function (chunks) { + var total = 0; + var i; + for (i = 0; i < chunks.length; i += 1) { + total += chunks[i].length; + } + var out = new Uint8Array(total); + var offset = 0; + for (i = 0; i < chunks.length; i += 1) { + out.set(chunks[i], offset); + offset += chunks[i].length; + } + return out; + }, + + hasPrefix: function (source, prefix) { + if (!source || !prefix || source.length < prefix.length) { + return false; + } + for (var i = 0; i < prefix.length; i += 1) { + if (source[i] !== prefix[i]) { + return false; + } + } + return true; + }, + + findSequence: function (source, sequence) { + if (!source || !sequence || sequence.length === 0 || source.length < sequence.length) { + return -1; + } + var maxIdx = source.length - sequence.length; + for (var i = 0; i <= maxIdx; i += 1) { + var match = true; + for (var j = 0; j < sequence.length; j += 1) { + if (source[i + j] !== sequence[j]) { + match = false; + break; + } + } + if (match) { + return i; + } + } + return -1; + }, + + toHex: function (bytes, limit) { + if (!bytes) return ""; + var max = Math.min(bytes.length, limit || 16); + var hex = []; + for (var i = 0; i < max; i += 1) { + hex.push(("0" + bytes[i].toString(16)).slice(-2)); + } + return hex.join(""); + }, + + toBinaryString: function (bytes) { + if (!bytes) return ""; + var chunkSize = 0x8000; + var parts = []; + for (var i = 0; i < bytes.length; i += chunkSize) { + var chunk = bytes.subarray(i, i + chunkSize); + parts.push(String.fromCharCode.apply(null, Array.prototype.slice.call(chunk))); + } + return parts.join(""); + }, + + fromBinaryString: function (str) { + if (!str) return new Uint8Array(0); + var out = new Uint8Array(str.length); + for (var i = 0; i < str.length; i += 1) { + out[i] = str.charCodeAt(i) & 0xff; + } + return out; + }, + + toBase64: function (bytes) { + var alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + var out = ""; + for (var i = 0; i < bytes.length; i += 3) { + var b0 = bytes[i]; + var b1 = i + 1 < bytes.length ? bytes[i + 1] : 0; + var b2 = i + 2 < bytes.length ? bytes[i + 2] : 0; + var triplet = (b0 << 16) | (b1 << 8) | b2; + out += alphabet[(triplet >> 18) & 0x3f]; + out += alphabet[(triplet >> 12) & 0x3f]; + out += i + 1 < bytes.length ? alphabet[(triplet >> 6) & 0x3f] : "="; + out += i + 2 < bytes.length ? alphabet[triplet & 0x3f] : "="; + } + return out; + }, + + toUint8Array: function (input) { + if (input == null) return null; + if (input instanceof Uint8Array) return input; + if (typeof ArrayBuffer !== "undefined" && input instanceof ArrayBuffer) return new Uint8Array(input); + if (typeof input === "string") return ByteUtils.fromBinaryString(input); + if (typeof input === "object" && typeof input.length === "number") return new Uint8Array(input); + if (typeof input === "object" && input.bytes && typeof input.bytes.length === "number") return new Uint8Array(input.bytes); + if (typeof input === "object" && input.data && typeof input.data.length === "number") return new Uint8Array(input.data); + return null; + } + }; + + /* ───────────────────────────────────────────────────────────── + 3. 纯 JS 64 位 Varint 编解码器(Pure JS 64-bit Varint) + 零 BigInt 依赖,原生兼容 iOS 12+ JavaScriptCore + ───────────────────────────────────────────────────────────── */ + var Varint64 = { + UINT32_MOD: 4294967296, + MAX_SAFE: 9007199254740991, + + fromUnsigned: function (val) { + var num = Number(val); + if (!Number.isFinite(num) || num < 0 || num > Varint64.MAX_SAFE) { + throw new Error("Invalid unsigned int64 value: " + val); + } + return { + low: num >>> 0, + high: Math.floor(num / Varint64.UINT32_MOD) >>> 0 + }; + }, + + fromSigned: function (val) { + var num = Math.trunc(Number(val)); + if (!Number.isFinite(num) || Math.abs(num) > Varint64.MAX_SAFE) { + throw new Error("Invalid signed int64 value: " + val); + } + if (num >= 0) { + return Varint64.fromUnsigned(num); + } + var pos = Varint64.fromUnsigned(-num); + var low = (~pos.low + 1) >>> 0; + var carry = low === 0 ? 1 : 0; + var high = (~pos.high + carry) >>> 0; + return { low: low, high: high }; + }, + + toSignedNumber: function (words) { + var low = words.low >>> 0; + var high = words.high >>> 0; + if ((high & 0x80000000) === 0) { + return (high >>> 0) * Varint64.UINT32_MOD + (low >>> 0); + } + var magLow = (~low + 1) >>> 0; + var carry = magLow === 0 ? 1 : 0; + var magHigh = (~high + carry) >>> 0; + var mag = magHigh * Varint64.UINT32_MOD + magLow; + return -mag; + }, + + encodeWords: function (words) { + var low = words.low >>> 0; + var high = words.high >>> 0; + var out = []; + while (high !== 0 || low >= 0x80) { + out.push((low & 0x7f) | 0x80); + low = ((low >>> 7) | (high << 25)) >>> 0; + high = high >>> 7; + } + out.push(low & 0x7f); + return ByteUtils.fromArray(out); + }, + + encodeUnsigned: function (val) { + return Varint64.encodeWords(Varint64.fromUnsigned(val)); + }, + + encodeSigned: function (val) { + return Varint64.encodeWords(Varint64.fromSigned(val)); + }, + + decode: function (bytes, offset) { + var low = 0; + var high = 0; + var shift = 0; + var current = offset || 0; + var count = 0; + + while (current < bytes.length && count < 10) { + var b = bytes[current]; + var payload = b & 0x7f; + current += 1; + count += 1; + + if (shift < 32) { + low = (low | ((payload << shift) >>> 0)) >>> 0; + if (shift > 25) { + high = (high | (payload >>> (32 - shift))) >>> 0; + } + } else { + high = (high | ((payload << (shift - 32)) >>> 0)) >>> 0; + } + + if ((b & 0x80) === 0) { + return { low: low, high: high, offset: current }; + } + shift += 7; + } + throw new Error("Truncated or malformed varint"); + } + }; + + /* ───────────────────────────────────────────────────────────── + 4. Protobuf 协议处理引擎 (Protobuf Streaming Engine) + ───────────────────────────────────────────────────────────── */ + var ProtobufEngine = { + readFields: function (bytes) { + var fields = []; + var offset = 0; + + while (offset < bytes.length) { + var fieldStart = offset; + var tagVarint = Varint64.decode(bytes, offset); + offset = tagVarint.offset; + + var tagVal = tagVarint.low; + var fieldNumber = tagVal >>> 3; + var wireType = tagVal & 0x07; + + if (fieldNumber <= 0) { + throw new Error("Invalid protobuf field number: " + fieldNumber); + } + + if (wireType === 0) { + // Varint + var valVarint = Varint64.decode(bytes, offset); + var rawBytes = bytes.subarray(fieldStart, valVarint.offset); + var valBytes = bytes.subarray(offset, valVarint.offset); + fields.push({ + fieldNumber: fieldNumber, + wireType: wireType, + raw: rawBytes, + valueBytes: valBytes, + varint: valVarint, + int64Value: Varint64.toSignedNumber(valVarint) + }); + offset = valVarint.offset; + } else if (wireType === 2) { + // Length-delimited (submessage, bytes, string) + var lenVarint = Varint64.decode(bytes, offset); + offset = lenVarint.offset; + var length = lenVarint.low; + if (offset + length > bytes.length) { + throw new Error("Protobuf length-delimited exceeds buffer bounds"); + } + var valueBytes = bytes.subarray(offset, offset + length); + offset += length; + fields.push({ + fieldNumber: fieldNumber, + wireType: wireType, + raw: bytes.subarray(fieldStart, offset), + valueBytes: valueBytes + }); + } else if (wireType === 5) { + // 32-bit fixed + if (offset + 4 > bytes.length) throw new Error("Protobuf fixed32 truncated"); + offset += 4; + fields.push({ + fieldNumber: fieldNumber, + wireType: wireType, + raw: bytes.subarray(fieldStart, offset) + }); + } else if (wireType === 1) { + // 64-bit fixed + if (offset + 8 > bytes.length) throw new Error("Protobuf fixed64 truncated"); + offset += 8; + fields.push({ + fieldNumber: fieldNumber, + wireType: wireType, + raw: bytes.subarray(fieldStart, offset) + }); + } else { + throw new Error("Unsupported wire type: " + wireType); + } + } + return fields; + }, + + safeReadFields: function (bytes) { + try { + if (!bytes || bytes.length === 0) return null; + var res = ProtobufEngine.readFields(bytes); + return res.length > 0 ? res : null; + } catch (err) { + return null; + } + }, + + makeVarintField: function (fieldNumber, signedOrUnsignedVal) { + var tagBytes = Varint64.encodeUnsigned((fieldNumber << 3) | 0); + var valBytes = typeof signedOrUnsignedVal === "number" && signedOrUnsignedVal < 0 + ? Varint64.encodeSigned(signedOrUnsignedVal) + : Varint64.encodeSigned(signedOrUnsignedVal); + return ByteUtils.concat([tagBytes, valBytes]); + }, + + makeLengthDelimitedField: function (fieldNumber, payloadBytes) { + var tagBytes = Varint64.encodeUnsigned((fieldNumber << 3) | 2); + var lenBytes = Varint64.encodeUnsigned(payloadBytes.length); + return ByteUtils.concat([tagBytes, lenBytes, payloadBytes]); + } + }; + + /* ───────────────────────────────────────────────────────────── + 5. Apple ARPC 封包处理 (Apple ARPC Framing Codec) + ───────────────────────────────────────────────────────────── */ + var ArpcCodec = { + readUInt16BE: function (bytes, offset) { + if (offset + 2 > bytes.length) throw new Error("UInt16 out of bounds"); + return (bytes[offset] << 8) | bytes[offset + 1]; + }, + + readUInt32BE: function (bytes, offset) { + if (offset + 4 > bytes.length) throw new Error("UInt32 out of bounds"); + return ( + (bytes[offset] * 0x1000000) + + ((bytes[offset + 1] << 16) | (bytes[offset + 2] << 8) | bytes[offset + 3]) + ) >>> 0; + }, + + writeUInt16BE: function (val) { + return ByteUtils.fromArray([(val >> 8) & 0xff, val & 0xff]); + }, + + writeUInt32BE: function (val) { + return ByteUtils.fromArray([ + (val >>> 24) & 0xff, + (val >>> 16) & 0xff, + (val >>> 8) & 0xff, + val & 0xff + ]); + }, + + readPascalString: function (bytes, state) { + var len = ArpcCodec.readUInt16BE(bytes, state.offset); + state.offset += 2; + if (state.offset + len > bytes.length) throw new Error("ARPC string out of bounds"); + var chars = []; + for (var i = 0; i < len; i += 1) { + chars.push(String.fromCharCode(bytes[state.offset + i])); + } + state.offset += len; + return chars.join(""); + }, + + writePascalString: function (str) { + var bytes = new Uint8Array(str.length); + for (var i = 0; i < str.length; i += 1) { + bytes[i] = str.charCodeAt(i) & 0x7f; + } + return ByteUtils.concat([ArpcCodec.writeUInt16BE(bytes.length), bytes]); + }, + + parse: function (bytes) { + var state = { offset: 0 }; + var version = ArpcCodec.readUInt16BE(bytes, state.offset); + state.offset += 2; + var locale = ArpcCodec.readPascalString(bytes, state); + var appIdentifier = ArpcCodec.readPascalString(bytes, state); + var osVersion = ArpcCodec.readPascalString(bytes, state); + var functionId = ArpcCodec.readUInt32BE(bytes, state.offset); + state.offset += 4; + var payloadLength = ArpcCodec.readUInt32BE(bytes, state.offset); + state.offset += 4; + + if (state.offset + payloadLength > bytes.length) { + throw new Error("ARPC payload length out of bounds"); + } + + return { + version: version, + locale: locale, + appIdentifier: appIdentifier, + osVersion: osVersion, + functionId: functionId, + payload: bytes.slice(state.offset, state.offset + payloadLength) + }; + }, + + serialize: function (arpc) { + return ByteUtils.concat([ + ArpcCodec.writeUInt16BE(arpc.version), + ArpcCodec.writePascalString(arpc.locale), + ArpcCodec.writePascalString(arpc.appIdentifier), + ArpcCodec.writePascalString(arpc.osVersion), + ArpcCodec.writeUInt32BE(arpc.functionId), + ArpcCodec.writeUInt32BE(arpc.payload.length), + arpc.payload + ]); + } + }; + + /* ───────────────────────────────────────────────────────────── + 6. WLOC 定位篡改核心 (WLOC Minimal Spoofer & Raw Scanner) + ───────────────────────────────────────────────────────────── */ + function coordToInt(deg) { + return Math.round(Number(deg) * 100000000); + } + + // 最小改写 Location 子消息:仅替换已存在的 纬度(1)/经度(2)/水平精度(3),原值透传其余所有字段 + function patchLocationRecord(locationBytes, config) { + if (!locationBytes || locationBytes.length === 0) return locationBytes; + var fields = ProtobufEngine.readFields(locationBytes); + var hasLat = false; + var hasLon = false; + var i; + + for (i = 0; i < fields.length; i += 1) { + if (fields[i].fieldNumber === 1 && fields[i].wireType === 0) hasLat = true; + if (fields[i].fieldNumber === 2 && fields[i].wireType === 0) hasLon = true; + } + + // 若无经纬度结构,原样放行,避免破坏响应结构 + if (!hasLat || !hasLon) { + return locationBytes; + } + + var parts = []; + for (i = 0; i < fields.length; i += 1) { + var f = fields[i]; + if (f.fieldNumber === 1 && f.wireType === 0) { + parts.push(ProtobufEngine.makeVarintField(1, coordToInt(config.latitude))); + } else if (f.fieldNumber === 2 && f.wireType === 0) { + parts.push(ProtobufEngine.makeVarintField(2, coordToInt(config.longitude))); + } else if (f.fieldNumber === 3 && f.wireType === 0) { + parts.push(ProtobufEngine.makeVarintField(3, config.horizontalAccuracy)); + } else { + parts.push(f.raw); + } + } + return ByteUtils.concat(parts); + } + + function patchWifiEntity(wifiBytes, config) { + var fields = ProtobufEngine.readFields(wifiBytes); + var parts = []; + for (var i = 0; i < fields.length; i += 1) { + var f = fields[i]; + if (f.fieldNumber === 2 && f.wireType === 2) { + parts.push(ProtobufEngine.makeLengthDelimitedField(2, patchLocationRecord(f.valueBytes, config))); + } else { + parts.push(f.raw); + } + } + return ByteUtils.concat(parts); + } + + function patchCellEntity(cellBytes, config) { + var fields = ProtobufEngine.readFields(cellBytes); + var parts = []; + for (var i = 0; i < fields.length; i += 1) { + var f = fields[i]; + if (f.fieldNumber === 5 && f.wireType === 2) { + parts.push(ProtobufEngine.makeLengthDelimitedField(5, patchLocationRecord(f.valueBytes, config))); + } else { + parts.push(f.raw); + } + } + return ByteUtils.concat(parts); + } + + function patchWlocPayload(payloadBytes, config) { + var fields = ProtobufEngine.readFields(payloadBytes); + var parts = []; + var wifiCount = 0; + var cellCount = 0; + + for (var i = 0; i < fields.length; i += 1) { + var f = fields[i]; + if (f.fieldNumber === 2 && f.wireType === 2) { + parts.push(ProtobufEngine.makeLengthDelimitedField(2, patchWifiEntity(f.valueBytes, config))); + wifiCount += 1; + } else if (CELL_RESPONSE_TAGS[f.fieldNumber] && f.wireType === 2) { + parts.push(ProtobufEngine.makeLengthDelimitedField(f.fieldNumber, patchCellEntity(f.valueBytes, config))); + cellCount += 1; + } else { + // 关键:其余根级字段(包括 3, 4, 33 等)全量原样透传,不丢弃! + parts.push(f.raw); + } + } + + return { + payload: ByteUtils.concat(parts), + wifiCount: wifiCount, + cellCount: cellCount + }; + } + + function buildSyntheticResponse(payload, prefix) { + var pref = prefix || APPLE_SYNTHETIC_PREFIX; + return ByteUtils.concat([pref, ArpcCodec.writeUInt16BE(payload.length), payload]); + } + + function looksLikeWlocPayload(bytes) { + if (!bytes || bytes.length === 0) return false; + var tag = bytes[0]; + var fieldNumber = tag >> 3; + var wireType = tag & 0x07; + return fieldNumber > 0 && (wireType === 0 || wireType === 2); + } + + // 滑窗特征扫描兜底:当遭遇未知前缀头部时,自动在 0~256 字节内滑窗定位 WLOC protobuf + function scanPatchRawBuffer(responseBytes, config) { + if (!responseBytes || responseBytes.length < 8) { + throw new Error("Response buffer too short for raw scan"); + } + + var offsets = []; + var i; + var frameLimit = Math.min(96, Math.max(0, responseBytes.length - 10)); + for (i = 0; i <= frameLimit; i += 2) offsets.push(i); + var rawLimit = Math.min(256, Math.max(0, responseBytes.length - 4)); + for (i = 0; i <= rawLimit; i += 1) { + if (offsets.indexOf(i) < 0) offsets.push(i); + } + + for (i = 0; i < offsets.length; i += 1) { + var offset = offsets[i]; + try { + var slice = responseBytes.subarray(offset); + if (!looksLikeWlocPayload(slice)) continue; + var patched = patchWlocPayload(slice, config); + if (patched.wifiCount > 0 || patched.cellCount > 0) { + return { + response: buildSyntheticResponse(patched.payload), + payload: patched.payload, + wifiCount: patched.wifiCount, + cellCount: patched.cellCount, + kind: "raw", + offset: offset + }; + } + } catch (e) { + // Continue scanning + } + } + throw new Error("Raw scan found no valid patchable WLOC payload"); + } + + function extractEnvelope(responseBytes) { + if (!responseBytes || responseBytes.length < 2) { + throw new Error("Response body is empty or too short"); + } + + // 1. Prefixed synthetic format + if (responseBytes.length >= 10 && responseBytes[0] === 0x00 && responseBytes[1] === 0x01 && responseBytes[6] === 0x00 && responseBytes[7] === 0x00) { + var payloadLen = ArpcCodec.readUInt16BE(responseBytes, 8); + if (payloadLen > 0 && 10 + payloadLen <= responseBytes.length) { + var payloadCandidate = responseBytes.subarray(10, 10 + payloadLen); + if (ProtobufEngine.safeReadFields(payloadCandidate) !== null) { + return { + kind: "synthetic", + payload: payloadCandidate, + prefix: responseBytes.subarray(0, 8), + suffix: responseBytes.subarray(10 + payloadLen) + }; + } + } + } + + // 2. Structured ARPC format + try { + var arpc = ArpcCodec.parse(responseBytes); + if (arpc.payload.length > 0 && ProtobufEngine.safeReadFields(arpc.payload) !== null) { + return { + kind: "arpc", + payload: arpc.payload, + arpc: arpc + }; + } + } catch (e) { + // Not standard ARPC + } + + // 3. Marker signature format + var markerIdx = ByteUtils.findSequence(responseBytes, APPLE_MARKER_SIGNATURE); + if (markerIdx >= 0) { + var lenOffset = markerIdx + APPLE_MARKER_SIGNATURE.length; + if (lenOffset + 2 <= responseBytes.length) { + var markerLen = ArpcCodec.readUInt16BE(responseBytes, lenOffset); + var pOffset = lenOffset + 2; + if (markerLen > 0 && pOffset + markerLen <= responseBytes.length) { + var markerPayload = responseBytes.subarray(pOffset, pOffset + markerLen); + if (ProtobufEngine.safeReadFields(markerPayload) !== null) { + return { + kind: "marker", + payload: markerPayload, + prefix: responseBytes.subarray(0, markerIdx), + markerAndLen: responseBytes.subarray(markerIdx, pOffset), + suffix: responseBytes.subarray(pOffset + markerLen) + }; + } + } + } + } + + // 4. Bare protobuf + if (looksLikeWlocPayload(responseBytes) && ProtobufEngine.safeReadFields(responseBytes) !== null) { + return { + kind: "bare", + payload: responseBytes + }; + } + + return null; + } + + function spoofAppleResponse(responseBytes, rawConfig) { + var config = normalizeConfig(rawConfig); + var extraction = null; + var strictError = null; + + try { + extraction = extractEnvelope(responseBytes); + } catch (err) { + strictError = err; + } + + if (extraction) { + var patched = patchWlocPayload(extraction.payload, config); + if (patched.wifiCount > 0 || patched.cellCount > 0) { + var finalResponse; + if (extraction.kind === "arpc") { + finalResponse = ArpcCodec.serialize({ + version: extraction.arpc.version, + locale: extraction.arpc.locale, + appIdentifier: extraction.arpc.appIdentifier, + osVersion: extraction.arpc.osVersion, + functionId: extraction.arpc.functionId, + payload: patched.payload + }); + } else if (extraction.kind === "marker") { + var newLen = ArpcCodec.writeUInt16BE(patched.payload.length); + finalResponse = ByteUtils.concat([ + extraction.prefix, + extraction.markerAndLen.subarray(0, APPLE_MARKER_SIGNATURE.length), + newLen, + patched.payload, + extraction.suffix + ]); + } else { + finalResponse = buildSyntheticResponse(patched.payload, extraction.prefix); + } + + return { + response: finalResponse, + payload: patched.payload, + wifiCount: patched.wifiCount, + cellCount: patched.cellCount, + kind: extraction.kind, + prefix: extraction.prefix ? ByteUtils.toHex(extraction.prefix, 8) : "" + }; + } + strictError = new Error("No patchable location submessages via " + extraction.kind); + } + + // 触发滑窗扫描兜底 + var rawScan = scanPatchRawBuffer(responseBytes, config); + return { + response: rawScan.response, + payload: rawScan.payload, + wifiCount: rawScan.wifiCount, + cellCount: rawScan.cellCount, + kind: rawScan.kind, + offset: rawScan.offset, + strictError: strictError ? strictError.message : null + }; + } + + /* ───────────────────────────────────────────────────────────── + 7. 参数解析与配置归一化 (Arguments & Config Normalization) + ───────────────────────────────────────────────────────────── */ + function normalizeConfig(input) { + var cfg = {}; + input = input || {}; + for (var k in CONFIG_DEFAULTS) { + if (Object.prototype.hasOwnProperty.call(CONFIG_DEFAULTS, k)) { + cfg[k] = CONFIG_DEFAULTS[k]; + } + } + for (var key in input) { + if (Object.prototype.hasOwnProperty.call(input, key) && input[key] !== undefined) { + cfg[key] = input[key]; + } + } + cfg.latitude = Number(cfg.latitude); + cfg.longitude = Number(cfg.longitude); + cfg.horizontalAccuracy = Number(cfg.horizontalAccuracy) || 39; + cfg.verticalAccuracy = Number(cfg.verticalAccuracy) || 1000; + cfg.altitude = Number(cfg.altitude) || 44; + cfg.debug = cfg.debug === true || cfg.debug === "true"; + cfg.failOpen = cfg.failOpen !== false && cfg.failOpen !== "false"; + return cfg; + } + + function parseArgumentString(argStr) { + var out = {}; + if (!argStr || typeof argStr !== "string") return out; + + var configUrlIdx = argStr.indexOf("configUrl="); + if (configUrlIdx >= 0) { + var after = argStr.slice(configUrlIdx + 10); + var endIdx = after.search(/[&,\s]/); + if (endIdx < 0) { + out.configUrl = after; + argStr = argStr.slice(0, configUrlIdx); + } else { + out.configUrl = after.slice(0, endIdx); + argStr = argStr.slice(0, configUrlIdx) + after.slice(endIdx); + } + } + + var tokens = argStr.split(/[&,]/); + for (var i = 0; i < tokens.length; i += 1) { + var token = tokens[i].trim(); + if (!token) continue; + var eq = token.indexOf("="); + if (eq > 0) { + var key = token.slice(0, eq).trim(); + var val = token.slice(eq + 1).trim(); + out[key] = val; + } + } + return out; + } + + /* ───────────────────────────────────────────────────────────── + 8. Shadowrocket 运行时接入 (Shadowrocket Runtime Integration) + ───────────────────────────────────────────────────────────── */ + function fetchRemoteConfig(url, callback) { + if (!url || typeof $httpClient === "undefined") { + callback(null); + return; + } + $httpClient.get({ url: url, timeout: 5 }, function (err, resp, data) { + if (err || !data || (resp && resp.status >= 400)) { + callback(null); + return; + } + try { + var json = JSON.parse(data); + callback(json); + } catch (e) { + callback(null); + } + }); + } + + function runShadowrocket() { + var hasResponse = typeof $response !== "undefined" && $response != null; + var hasRequest = typeof $request !== "undefined" && $request != null; + + if (!hasResponse && !hasRequest) { + return; + } + + var scriptArgs = typeof $argument === "string" ? parseArgumentString($argument) : {}; + var config = normalizeConfig(scriptArgs); + + function passThrough() { + $done({}); + } + + function completeResponse(bodyBytes, wifiCount, cellCount) { + var headers = ($response && $response.headers) ? $response.headers : {}; + delete headers["Content-Encoding"]; + delete headers["content-encoding"]; + headers["Content-Length"] = String(bodyBytes.length); + headers["X-Location-Spoofer"] = "active"; + headers["X-Location-Spoofer-Wifi"] = String(wifiCount); + headers["X-Location-Spoofer-Cell"] = String(cellCount); + + $done({ + response: { + status: 200, + headers: headers, + body: bodyBytes + } + }); + } + + function processRewrite(activeConfig) { + try { + var rawBody = ByteUtils.toUint8Array(($response && $response.body != null) ? $response.body : ($response && $response.bodyBytes)); + if (!rawBody || rawBody.length < 2) { + passThrough(); + return; + } + + var result = spoofAppleResponse(rawBody, activeConfig); + if (activeConfig.debug) { + console.log("[Location Spoofer] Patched " + result.wifiCount + " Wi-Fi, " + result.cellCount + " Cell. Format: " + result.kind); + } + completeResponse(result.response, result.wifiCount, result.cellCount); + } catch (err) { + if (activeConfig.debug) { + console.log("[Location Spoofer] Failed: " + err.message); + } + if (activeConfig.failOpen) { + passThrough(); + } else { + $done({ + response: { + status: "HTTP/1.1 500 Internal Server Error", + headers: { "Content-Type": "text/plain" }, + body: "Location spoofing failed: " + err.message + } + }); + } + } + } + + if (config.configUrl) { + fetchRemoteConfig(config.configUrl, function (remoteData) { + if (remoteData) { + if (remoteData.latitude != null) config.latitude = Number(remoteData.latitude); + if (remoteData.longitude != null) config.longitude = Number(remoteData.longitude); + if (remoteData.horizontalAccuracy != null) config.horizontalAccuracy = Number(remoteData.horizontalAccuracy); + if (remoteData.verticalAccuracy != null) config.verticalAccuracy = Number(remoteData.verticalAccuracy); + if (remoteData.altitude != null) config.altitude = Number(remoteData.altitude); + } + processRewrite(config); + }); + } else { + processRewrite(config); + } + } + + /* ───────────────────────────────────────────────────────────── + 9. 模块导出与入口 (Module Export & Entry Point) + ───────────────────────────────────────────────────────────── */ + var api = { + CONFIG_DEFAULTS: CONFIG_DEFAULTS, + APPLE_SYNTHETIC_PREFIX: APPLE_SYNTHETIC_PREFIX, + APPLE_MARKER_SIGNATURE: APPLE_MARKER_SIGNATURE, + ByteUtils: ByteUtils, + Varint64: Varint64, + ProtobufEngine: ProtobufEngine, + ArpcCodec: ArpcCodec, + coordToInt: coordToInt, + normalizeConfig: normalizeConfig, + parseArgumentString: parseArgumentString, + patchLocationRecord: patchLocationRecord, + patchWifiEntity: patchWifiEntity, + patchCellEntity: patchCellEntity, + patchWlocPayload: patchWlocPayload, + buildSyntheticResponse: buildSyntheticResponse, + scanPatchRawBuffer: scanPatchRawBuffer, + extractEnvelope: extractEnvelope, + spoofAppleResponse: spoofAppleResponse, + uint64ToSignedNumber: Varint64.toSignedNumber, + // 兼容测试套件别名 + concatBytes: ByteUtils.concat, + decodeVarint: Varint64.decode, + encodeVarintUnsigned: Varint64.encodeUnsigned, + encodeVarintSignedInt64: Varint64.encodeSigned, + makeVarintField: ProtobufEngine.makeVarintField, + makeLengthDelimitedField: ProtobufEngine.makeLengthDelimitedField, + parseFields: ProtobufEngine.readFields, + buildAppleWLocResponse: buildSyntheticResponse + }; + + if (typeof module !== "undefined" && module.exports) { + module.exports = api; + } else { + runShadowrocket(); + } +}()); diff --git a/public/manifest.json b/public/manifest.json new file mode 100644 index 0000000..e827948 --- /dev/null +++ b/public/manifest.json @@ -0,0 +1,18 @@ +{ + "name": "GPS Spoofer", + "short_name": "GPS Spoofer", + "description": "iOS GPS 模拟定位 Web 管理面板", + "start_url": "/", + "display": "standalone", + "background_color": "#f2efe9", + "theme_color": "#f2efe9", + "orientation": "portrait", + "icons": [ + { + "src": "/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ] +} diff --git a/public/screenshots/1-map-picker.jpg b/public/screenshots/1-map-picker.jpg new file mode 100644 index 0000000..53033b7 Binary files /dev/null and b/public/screenshots/1-map-picker.jpg differ diff --git a/public/screenshots/2-favorites.jpg b/public/screenshots/2-favorites.jpg new file mode 100644 index 0000000..afae84d Binary files /dev/null and b/public/screenshots/2-favorites.jpg differ diff --git a/public/screenshots/3-shadowrocket-config.jpg b/public/screenshots/3-shadowrocket-config.jpg new file mode 100644 index 0000000..1b025b1 Binary files /dev/null and b/public/screenshots/3-shadowrocket-config.jpg differ diff --git a/public/screenshots/4-map-layers.jpg b/public/screenshots/4-map-layers.jpg new file mode 100644 index 0000000..6997e88 Binary files /dev/null and b/public/screenshots/4-map-layers.jpg differ diff --git a/public/screenshots/5-advanced-params.jpg b/public/screenshots/5-advanced-params.jpg new file mode 100644 index 0000000..44b80b0 Binary files /dev/null and b/public/screenshots/5-advanced-params.jpg differ diff --git a/public/screenshots/6-search-history.jpg b/public/screenshots/6-search-history.jpg new file mode 100644 index 0000000..264a66d Binary files /dev/null and b/public/screenshots/6-search-history.jpg differ diff --git a/server.mjs b/server.mjs new file mode 100644 index 0000000..c473530 --- /dev/null +++ b/server.mjs @@ -0,0 +1,276 @@ +import { createServer } from 'node:http'; +import { createReadStream } from 'node:fs'; +import { mkdir, readFile, rename, stat, writeFile } from 'node:fs/promises'; +import { extname, join, normalize, resolve } from 'node:path'; +import { timingSafeEqual } from 'node:crypto'; + +const HOST = process.env.HOST || '0.0.0.0'; +const PORT = Number.parseInt(process.env.PORT || '8080', 10); +const TOKEN = process.env.TOKEN || ''; +const AMAP_KEY = process.env.AMAP_KEY || ''; +const DATA_DIR = resolve(process.env.DATA_DIR || './data'); +const DATA_FILE = join(DATA_DIR, 'spoofer.json'); +const PUBLIC_DIR = resolve('./public'); +const MAX_BODY = 64 * 1024; + +const DEFAULT_LOC = { + latitude: 39.90872, + longitude: 116.39748, + altitude: 44, + horizontalAccuracy: 39, + verticalAccuracy: 1000 +}; + +const MIME = { + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.svg': 'image/svg+xml', + '.ico': 'image/x-icon', + '.webmanifest': 'application/manifest+json; charset=utf-8', + '.sgmodule': 'text/plain; charset=utf-8' +}; + +let store = { loc: { ...DEFAULT_LOC }, favorites: [] }; +let saveQueue = Promise.resolve(); + +function safeEqual(left, right) { + const a = Buffer.from(left); + const b = Buffer.from(right); + return a.length === b.length && timingSafeEqual(a, b); +} + +function authorized(url) { + return TOKEN && safeEqual(url.searchParams.get('token') || '', TOKEN); +} + +function commonHeaders(extra = {}) { + return { + 'Access-Control-Allow-Origin': '*', + 'Cache-Control': 'no-store', + ...extra + }; +} + +function sendJson(res, data, status = 200) { + res.writeHead(status, commonHeaders({ 'Content-Type': 'application/json; charset=utf-8' })); + res.end(JSON.stringify(data)); +} + +function rejectUnauthorized(res) { + sendJson(res, { error: 'unauthorized' }, 401); +} + +async function readJson(req) { + const chunks = []; + let size = 0; + for await (const chunk of req) { + size += chunk.length; + if (size > MAX_BODY) throw new Error('body too large'); + chunks.push(chunk); + } + return JSON.parse(Buffer.concat(chunks).toString('utf8')); +} + +async function persist() { + const snapshot = JSON.stringify(store, null, 2); + saveQueue = saveQueue.then(async () => { + const temporary = `${DATA_FILE}.tmp`; + await writeFile(temporary, snapshot, { mode: 0o600 }); + await rename(temporary, DATA_FILE); + }); + return saveQueue; +} + +async function initializeStore() { + await mkdir(DATA_DIR, { recursive: true }); + try { + const saved = JSON.parse(await readFile(DATA_FILE, 'utf8')); + if (saved?.loc && Array.isArray(saved?.favorites)) { + store = saved; + return; + } + } catch (error) { + if (error.code !== 'ENOENT') console.warn(`Ignoring invalid data file: ${error.message}`); + } + await persist(); +} + +function externalOrigin(req) { + const forwardedProto = req.headers['x-forwarded-proto']?.split(',')[0].trim(); + const forwardedHost = req.headers['x-forwarded-host']?.split(',')[0].trim(); + const protocol = forwardedProto || (req.socket.encrypted ? 'https' : 'http'); + const host = forwardedHost || req.headers.host || `localhost:${PORT}`; + return `${protocol}://${host}`; +} + +async function serveIndex(res) { + let html = await readFile(join(PUBLIC_DIR, 'index.html'), 'utf8'); + const config = JSON.stringify({ hasToken: true, amapKey: AMAP_KEY }).replaceAll('<', '\\u003c'); + html = html.replace('', ``); + res.writeHead(200, { 'Content-Type': MIME['.html'], 'Cache-Control': 'no-store' }); + res.end(html); +} + +async function serveModule(req, res, url) { + let content = await readFile(join(PUBLIC_DIR, 'ios-location-spoofer.sgmodule'), 'utf8'); + const origin = externalOrigin(req); + const token = url.searchParams.get('token') || ''; + content = content + .replaceAll('https://你的域名', origin) + .replaceAll('http://你的域名', origin) + .replaceAll('你的域名', new URL(origin).host) + .replaceAll('你的Token', token); + res.writeHead(200, commonHeaders({ 'Content-Type': MIME['.sgmodule'] })); + res.end(content); +} + +async function serveStatic(pathname, res) { + const relative = normalize(decodeURIComponent(pathname)).replace(/^[/\\]+/, ''); + const file = resolve(PUBLIC_DIR, relative); + if (file !== PUBLIC_DIR && !file.startsWith(`${PUBLIC_DIR}${process.platform === 'win32' ? '\\' : '/'}`)) return false; + try { + const info = await stat(file); + if (!info.isFile()) return false; + res.writeHead(200, { + 'Content-Type': MIME[extname(file).toLowerCase()] || 'application/octet-stream', + 'Cache-Control': 'public, max-age=3600' + }); + createReadStream(file).pipe(res); + return true; + } catch { + return false; + } +} + +function validCoordinate(value, min, max) { + return typeof value === 'number' && Number.isFinite(value) && value >= min && value <= max; +} + +async function handleApi(req, res, url) { + if (!authorized(url)) { + rejectUnauthorized(res); + return true; + } + + if (url.pathname === '/verify' && req.method === 'GET') { + sendJson(res, { ok: true }); + return true; + } + + if (url.pathname === '/loc.json' && req.method === 'GET') { + sendJson(res, store.loc); + return true; + } + + if (url.pathname === '/set' && req.method === 'POST') { + try { + const data = await readJson(req); + const updated = { ...store.loc }; + if ('latitude' in data && !validCoordinate(data.latitude, -90, 90)) throw new Error('bad latitude'); + if ('longitude' in data && !validCoordinate(data.longitude, -180, 180)) throw new Error('bad longitude'); + for (const field of ['latitude', 'longitude', 'altitude', 'horizontalAccuracy', 'verticalAccuracy']) { + if (typeof data[field] === 'number' && Number.isFinite(data[field])) updated[field] = data[field]; + } + store.loc = updated; + await persist(); + sendJson(res, updated); + } catch (error) { + sendJson(res, { error: error.message || 'bad json' }, 400); + } + return true; + } + + if (url.pathname === '/favorites' && req.method === 'GET') { + sendJson(res, store.favorites); + return true; + } + + if (url.pathname === '/favorites' && req.method === 'POST') { + try { + const data = await readJson(req); + if (!validCoordinate(data.latitude, -90, 90) || !validCoordinate(data.longitude, -180, 180)) { + throw new Error('bad coordinates'); + } + const favorite = { + id: Date.now().toString(36), + name: String(data.name || '未命名').slice(0, 30), + latitude: data.latitude, + longitude: data.longitude, + altitude: Number.isFinite(data.altitude) ? data.altitude : null, + horizontalAccuracy: Number.isFinite(data.horizontalAccuracy) ? data.horizontalAccuracy : null, + verticalAccuracy: Number.isFinite(data.verticalAccuracy) ? data.verticalAccuracy : null, + createdAt: new Date().toISOString() + }; + store.favorites.unshift(favorite); + store.favorites = store.favorites.slice(0, 100); + await persist(); + sendJson(res, favorite); + } catch (error) { + sendJson(res, { error: error.message || 'bad json' }, 400); + } + return true; + } + + const favoriteMatch = url.pathname.match(/^\/favorites\/([^/]+)$/); + if (favoriteMatch && req.method === 'DELETE') { + const id = decodeURIComponent(favoriteMatch[1]); + store.favorites = store.favorites.filter((favorite) => favorite.id !== id); + await persist(); + sendJson(res, { ok: true }); + return true; + } + + return false; +} + +await initializeStore(); + +if (!TOKEN) { + console.error('TOKEN is required. Refusing to start an unauthenticated location service.'); + process.exit(1); +} + +const server = createServer(async (req, res) => { + try { + const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`); + + if (req.method === 'OPTIONS') { + res.writeHead(204, commonHeaders({ + 'Access-Control-Allow-Methods': 'GET,POST,DELETE,OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type' + })); + res.end(); + return; + } + + if (url.pathname === '/healthz' && req.method === 'GET') { + sendJson(res, { ok: true }); + return; + } + if (url.pathname === '/' && req.method === 'GET') { + await serveIndex(res); + return; + } + if (url.pathname === '/ios-location-spoofer.sgmodule' && req.method === 'GET') { + if (!authorized(url)) return rejectUnauthorized(res); + await serveModule(req, res, url); + return; + } + if (await handleApi(req, res, url)) return; + if (req.method === 'GET' && await serveStatic(url.pathname, res)) return; + sendJson(res, { error: 'not found' }, 404); + } catch (error) { + console.error(error); + if (!res.headersSent) sendJson(res, { error: 'internal server error' }, 500); + else res.end(); + } +}); + +server.listen(PORT, HOST, () => { + console.log(`iOS Location Spoofer listening on http://${HOST}:${PORT}`); +}); +