fix: 坐标转换修复、图钉键盘对齐、WiFi监控、UI优化
- 坐标转换: detectType 用地理边界判断瓦片类型,加载路径去掉重复 toStored - 图钉: 放 MKMapView 内部 + keyboard 通知双保险,针尖对齐地理中心 - 缩放控件: 转 UIKit 子视图放地图内,键盘弹起一起移动 - 网络监控: WiFi SSID 轮询 + 重连检测,虚拟定位激活时自动验证 - UI: 去飞行模式弹窗和面板,引导页只在首次安装和设置入口触发 - 实时定位: 蓝点数据优先,不限时缓存
@@ -4,7 +4,6 @@ struct ContentView: View {
|
||||
@StateObject private var setup = SetupCoordinator()
|
||||
@ObservedObject private var net = NetworkMonitor.shared
|
||||
@State private var showSetup = false
|
||||
@State private var showEnableTip = false
|
||||
@AppStorage("setupCompleted") private var setupCompleted = false
|
||||
|
||||
var body: some View {
|
||||
@@ -12,7 +11,6 @@ struct ContentView: View {
|
||||
MapHomeView(setup: setup)
|
||||
}
|
||||
.task {
|
||||
// 首次打开无标记:必须进引导页
|
||||
if !setupCompleted {
|
||||
showSetup = true
|
||||
return
|
||||
@@ -21,10 +19,7 @@ struct ContentView: View {
|
||||
}
|
||||
.onChange(of: net.isAirplaneMode) { airplane in
|
||||
guard setupCompleted else { return }
|
||||
if airplane {
|
||||
showEnableTip = true
|
||||
} else {
|
||||
showEnableTip = false
|
||||
if !airplane {
|
||||
Task { await setup.refreshTrust() }
|
||||
}
|
||||
}
|
||||
@@ -35,37 +30,5 @@ struct ContentView: View {
|
||||
showSetup = false
|
||||
})
|
||||
}
|
||||
// 设置页「进入引导页」入口联动
|
||||
.onChange(of: setup.needsSetup) { needs in
|
||||
if needs { showSetup = true }
|
||||
}
|
||||
.sheet(isPresented: $showEnableTip) {
|
||||
NavigationView {
|
||||
VStack(spacing: 20) {
|
||||
Image(systemName: "airplane")
|
||||
.font(.system(size: 48))
|
||||
.foregroundStyle(.orange)
|
||||
Text("飞行模式已开启")
|
||||
.font(.title3.weight(.semibold))
|
||||
Text("Wi‑Fi 和蜂窝数据已关闭,虚拟定位无法生效。请关闭飞行模式后重试。")
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
Button("知道了") {
|
||||
showEnableTip = false
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.padding(30)
|
||||
.navigationTitle("提示")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("完成") { showEnableTip = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,9 +56,8 @@ final class LocationActionCoordinator: ObservableObject {
|
||||
}
|
||||
|
||||
private func commit(_ favorite: FavoriteLocation) -> Bool {
|
||||
// MKMapView 在中国地区使用高德瓦片(GCJ-02),返回的坐标是 GCJ-02。
|
||||
// 但 Apple wloc 定位服务使用 WGS-84,因此写入代理前需要转换为 WGS-84。
|
||||
let wgs = CoordinateConverter.gcj02ToWgs84(lat: favorite.latitude, lon: favorite.longitude)
|
||||
// 统一转为 WGS-84 存储(地图取点可能为当前瓦片坐标系)
|
||||
let wgs = CoordinateConverter.toStored(lat: favorite.latitude, lon: favorite.longitude)
|
||||
WlocSettingsStore.save(WlocSettings(
|
||||
longitude: wgs.lon,
|
||||
latitude: wgs.lat,
|
||||
@@ -71,6 +70,10 @@ final class LocationActionCoordinator: ObservableObject {
|
||||
enabled: true,
|
||||
accuracy: favorite.accuracy
|
||||
)
|
||||
RuntimeLogger.info("APP", "坐标转换", "写入代理: WGS-84", details: [
|
||||
"lat": String(wgs.lat),
|
||||
"lon": String(wgs.lon)
|
||||
])
|
||||
state = .idle
|
||||
virtualLocationEnabled = true
|
||||
message = "虚拟定位已开启"
|
||||
|
||||
@@ -66,10 +66,11 @@ struct MapHomeView: View {
|
||||
self.setup = setup
|
||||
let savedCoord = LastCoordinateStore.load()
|
||||
let initialZoom = ViewportStore.loadOrDefault()
|
||||
// 坐标:缓存 → 先给兜底深圳(启动后会由 initializeMap 按优先级覆盖)
|
||||
// 持久化存的是 WGS-84,直接转当前瓦片坐标系显示
|
||||
let initialCoord: CLLocationCoordinate2D
|
||||
if let coord = savedCoord?.coordinate {
|
||||
initialCoord = coord
|
||||
let display = CoordinateConverter.toDisplay(lat: coord.latitude, lon: coord.longitude)
|
||||
initialCoord = CLLocationCoordinate2D(latitude: display.lat, longitude: display.lon)
|
||||
} else {
|
||||
initialCoord = CLLocationCoordinate2D(latitude: 22.544577, longitude: 113.94114)
|
||||
}
|
||||
@@ -89,10 +90,12 @@ struct MapHomeView: View {
|
||||
},
|
||||
onUserCenterChanged: { coordinate, distance in
|
||||
mapState.updateViewport(distanceMeters: distance)
|
||||
CoordinateConverter.updateTileType(lat: coordinate.latitude, lon: coordinate.longitude)
|
||||
let previousRevision = mapState.selection.revision
|
||||
let revision = mapState.selectUserMapCenter(coordinate)
|
||||
guard revision != previousRevision else { return }
|
||||
LastCoordinateStore.save(lat: coordinate.latitude, lon: coordinate.longitude)
|
||||
let wgs = CoordinateConverter.toStored(lat: coordinate.latitude, lon: coordinate.longitude)
|
||||
LastCoordinateStore.save(lat: wgs.lat, lon: wgs.lon)
|
||||
favorites.select(nil)
|
||||
scheduleGeocode(coordinate: coordinate, revision: revision)
|
||||
},
|
||||
@@ -101,35 +104,19 @@ struct MapHomeView: View {
|
||||
},
|
||||
onMapTap: { coordinate in
|
||||
favorites.select(nil)
|
||||
CoordinateConverter.updateTileType(lat: coordinate.latitude, lon: coordinate.longitude)
|
||||
let revision = mapState.selectMapTap(coordinate)
|
||||
LastCoordinateStore.save(lat: coordinate.latitude, lon: coordinate.longitude)
|
||||
let wgs = CoordinateConverter.toStored(lat: coordinate.latitude, lon: coordinate.longitude)
|
||||
LastCoordinateStore.save(lat: wgs.lat, lon: wgs.lon)
|
||||
scheduleGeocode(coordinate: coordinate, revision: revision)
|
||||
},
|
||||
onUserZoomChanged: { distance in
|
||||
ViewportStore.save(distance)
|
||||
}
|
||||
},
|
||||
onZoomIn: { mapState.zoom(by: 0.5) },
|
||||
onZoomOut: { mapState.zoom(by: 2) }
|
||||
)
|
||||
.ignoresSafeArea()
|
||||
.ignoresSafeArea(.keyboard)
|
||||
.overlay {
|
||||
Image(systemName: "mappin.and.ellipse")
|
||||
.font(.system(size: 38, weight: .semibold))
|
||||
.symbolRenderingMode(.palette)
|
||||
.foregroundStyle(.white, .red)
|
||||
.shadow(color: .black.opacity(0.28), radius: 5, y: 3)
|
||||
.offset(y: -19)
|
||||
.allowsHitTesting(false)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
|
||||
HStack {
|
||||
zoomControls
|
||||
Spacer()
|
||||
}
|
||||
.padding(.leading, 16)
|
||||
.padding(.top, 130)
|
||||
.allowsHitTesting(true)
|
||||
.allowsHitTesting(true)
|
||||
.ignoresSafeArea(.container)
|
||||
|
||||
VStack(spacing: 10) {
|
||||
topControls
|
||||
@@ -195,12 +182,21 @@ struct MapHomeView: View {
|
||||
)) {
|
||||
Button("知道了", role: .cancel) {}
|
||||
} message: { Text(manualHint) }
|
||||
.onAppear(perform: initializeMap)
|
||||
.onAppear {
|
||||
initializeMap()
|
||||
NetworkMonitor.shared.onWiFiChanged = { [self] in
|
||||
guard spoofState == .active else { return }
|
||||
Task { @MainActor in
|
||||
let target = currentSelectionFavorite
|
||||
let result = await setup.runVerificationTest(testLat: target.latitude, testLon: target.longitude)
|
||||
if !result.isSuccess, let tip = result.tipKind {
|
||||
activeTip = tip
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: net.isAirplaneMode) { airplane in
|
||||
if airplane {
|
||||
showEnableTip = true
|
||||
} else {
|
||||
showEnableTip = false
|
||||
if !airplane {
|
||||
Task { await setup.refreshTrust() }
|
||||
}
|
||||
}
|
||||
@@ -362,14 +358,16 @@ struct MapHomeView: View {
|
||||
return
|
||||
}
|
||||
let snapshot = currentSelectionFavorite
|
||||
let wgs = CoordinateConverter.toStored(lat: snapshot.latitude, lon: snapshot.longitude)
|
||||
let favorite = favorites.save(
|
||||
name: snapshot.name,
|
||||
latitude: snapshot.latitude,
|
||||
longitude: snapshot.longitude,
|
||||
latitude: wgs.lat,
|
||||
longitude: wgs.lon,
|
||||
accuracy: snapshot.accuracy
|
||||
)
|
||||
let display = CoordinateConverter.toDisplay(lat: favorite.latitude, lon: favorite.longitude)
|
||||
mapState.selectFavorite(
|
||||
CLLocationCoordinate2D(latitude: favorite.latitude, longitude: favorite.longitude),
|
||||
CLLocationCoordinate2D(latitude: display.lat, longitude: display.lon),
|
||||
id: favorite.id,
|
||||
name: favorite.name
|
||||
)
|
||||
@@ -391,15 +389,6 @@ struct MapHomeView: View {
|
||||
HStack(spacing: 8) { ForEach(favorites.favorites) { f in favoriteChip(f) } }.padding(.vertical, 2)
|
||||
}
|
||||
}
|
||||
// 飞行模式
|
||||
if net.isAirplaneMode {
|
||||
HStack {
|
||||
Image(systemName: "airplane").foregroundStyle(.orange)
|
||||
Text("飞行模式已开启").font(.caption).foregroundStyle(.orange)
|
||||
Spacer()
|
||||
Button("查看说明") { activeTip = .activation }.font(.caption).buttonStyle(.bordered).tint(.orange)
|
||||
}
|
||||
}
|
||||
// 主控按钮(带动画)
|
||||
HStack(spacing: 10) {
|
||||
Button(action: handleMainButtonTap) {
|
||||
@@ -504,12 +493,24 @@ struct MapHomeView: View {
|
||||
activeSpoofLat = target.latitude
|
||||
activeSpoofLon = target.longitude
|
||||
}
|
||||
RuntimeLogger.info("APP", "定位", "验证结果", details: [
|
||||
"success": "true",
|
||||
"applied": String(applied),
|
||||
"spoofState": String(describing: spoofState)
|
||||
])
|
||||
if applied && !activationTipDisabled {
|
||||
showEnableTip = true
|
||||
activationTipCount += 1
|
||||
}
|
||||
} else {
|
||||
spoofState = actions.virtualLocationEnabled ? .active : .idle
|
||||
RuntimeLogger.warning("APP", "定位", "验证失败", details: [
|
||||
"result": result.id,
|
||||
"spoofState": String(describing: spoofState)
|
||||
])
|
||||
if let tip = result.tipKind {
|
||||
activeTip = tip
|
||||
}
|
||||
}
|
||||
locationOperationTask = nil
|
||||
}
|
||||
@@ -572,51 +573,14 @@ struct MapHomeView: View {
|
||||
|
||||
private var testFavorite: FavoriteLocation { currentSelectionFavorite }
|
||||
|
||||
private var zoomControls: some View {
|
||||
VStack(spacing: 0) {
|
||||
Button {
|
||||
mapState.zoom(by: 0.5)
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
.font(.system(size: 22, weight: .bold))
|
||||
.frame(width: 52, height: 52)
|
||||
}
|
||||
.accessibilityLabel("放大地图")
|
||||
|
||||
Divider().frame(width: 28)
|
||||
|
||||
Text(MapZoomMath.viewportScaleLabel(distanceMeters: mapState.viewportMeters))
|
||||
.font(.system(size: 9, weight: .semibold, design: .rounded))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.7)
|
||||
.frame(width: 54, height: 28)
|
||||
.accessibilityLabel("当前地图范围")
|
||||
.accessibilityValue(MapZoomMath.viewportScaleLabel(distanceMeters: mapState.viewportMeters))
|
||||
|
||||
Divider().frame(width: 28)
|
||||
|
||||
Button {
|
||||
mapState.zoom(by: 2)
|
||||
} label: {
|
||||
Image(systemName: "minus")
|
||||
.font(.system(size: 22, weight: .bold))
|
||||
.frame(width: 52, height: 52)
|
||||
}
|
||||
.accessibilityLabel("缩小地图")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 13, style: .continuous))
|
||||
.shadow(color: .black.opacity(0.18), radius: 7, y: 3)
|
||||
}
|
||||
|
||||
private func initializeMap() {
|
||||
guard !mapDidInitialize else { return }
|
||||
mapDidInitialize = true
|
||||
|
||||
if let selected = favorites.selectedFavorite {
|
||||
let display = CoordinateConverter.toDisplay(lat: selected.latitude, lon: selected.longitude)
|
||||
mapState.selectFavorite(
|
||||
CLLocationCoordinate2D(latitude: selected.latitude, longitude: selected.longitude),
|
||||
CLLocationCoordinate2D(latitude: display.lat, longitude: display.lon),
|
||||
id: selected.id,
|
||||
name: selected.name
|
||||
)
|
||||
@@ -638,13 +602,13 @@ struct MapHomeView: View {
|
||||
|
||||
private func requestRealtimeLocation() {
|
||||
let intent = mapState.beginRealtimeIntent()
|
||||
if let nativeLocation = mapState.realtimeLocation,
|
||||
abs(nativeLocation.timestamp.timeIntervalSinceNow) <= 30 {
|
||||
acceptRealtimeLocation(nativeLocation.coordinate, intent: intent, source: "MapKit 实时位置")
|
||||
// 蓝点存在就直接用,不限时(MKMapView 的 userLocation 只在位置变化时才更新)
|
||||
if let loc = mapState.realtimeLocation {
|
||||
acceptRealtimeLocation(loc.coordinate, intent: intent, source: "MapKit蓝点")
|
||||
return
|
||||
}
|
||||
startRealtimeLocationRequest(
|
||||
source: "定位按钮兜底",
|
||||
source: "CLLocationManager",
|
||||
showFailureAlert: true,
|
||||
intent: intent
|
||||
)
|
||||
@@ -659,7 +623,7 @@ struct MapHomeView: View {
|
||||
// CLLocationManager fallback so the camera and dot cannot disagree.
|
||||
realtimeRequestContext = nil
|
||||
realtimeRequestTask?.cancel()
|
||||
acceptRealtimeLocation(location.coordinate, intent: context.intent, source: "MapKit \(context.source)")
|
||||
acceptRealtimeLocation(location.coordinate, intent: context.intent, source: "蓝点(途中)→\(context.source)")
|
||||
}
|
||||
|
||||
private func startRealtimeLocationRequest(
|
||||
@@ -711,7 +675,9 @@ struct MapHomeView: View {
|
||||
guard accepted else { return }
|
||||
// 用点击时的缩放级别居中,不改变缩放
|
||||
mapState.focusSelection(distanceMeters: currentViewport)
|
||||
LastCoordinateStore.save(lat: coordinate.latitude, lon: coordinate.longitude)
|
||||
CoordinateConverter.updateTileType(lat: coordinate.latitude, lon: coordinate.longitude)
|
||||
let wgs = CoordinateConverter.toStored(lat: coordinate.latitude, lon: coordinate.longitude)
|
||||
LastCoordinateStore.save(lat: wgs.lat, lon: wgs.lon)
|
||||
favorites.select(nil)
|
||||
scheduleGeocode(coordinate: coordinate, revision: mapState.selection.revision)
|
||||
}
|
||||
@@ -842,8 +808,10 @@ struct MapHomeView: View {
|
||||
geocodeDebounceTask?.cancel()
|
||||
reverseGeocodeTask?.cancel()
|
||||
favorites.select(nil)
|
||||
CoordinateConverter.updateTileType(lat: result.coordinate.latitude, lon: result.coordinate.longitude)
|
||||
mapState.selectSearchResult(result.coordinate, name: result.name)
|
||||
LastCoordinateStore.save(lat: result.coordinate.latitude, lon: result.coordinate.longitude)
|
||||
let wgs = CoordinateConverter.toStored(lat: result.coordinate.latitude, lon: result.coordinate.longitude)
|
||||
LastCoordinateStore.save(lat: wgs.lat, lon: wgs.lon)
|
||||
searchText = result.name
|
||||
searchResults = []
|
||||
searchError = ""
|
||||
@@ -857,8 +825,9 @@ struct MapHomeView: View {
|
||||
geocodeDebounceTask?.cancel()
|
||||
reverseGeocodeTask?.cancel()
|
||||
favorites.select(favorite.id)
|
||||
let display = CoordinateConverter.toDisplay(lat: favorite.latitude, lon: favorite.longitude)
|
||||
mapState.selectFavorite(
|
||||
CLLocationCoordinate2D(latitude: favorite.latitude, longitude: favorite.longitude),
|
||||
CLLocationCoordinate2D(latitude: display.lat, longitude: display.lon),
|
||||
id: favorite.id,
|
||||
name: favorite.name
|
||||
)
|
||||
|
||||
@@ -38,6 +38,8 @@ struct MapViewRepresentable: UIViewRepresentable {
|
||||
let onViewportChanged: (CLLocationDistance) -> Void
|
||||
let onMapTap: (CLLocationCoordinate2D) -> Void
|
||||
let onUserZoomChanged: ((CLLocationDistance) -> Void)?
|
||||
var onZoomIn: (() -> Void)?
|
||||
var onZoomOut: (() -> Void)?
|
||||
|
||||
func makeCoordinator() -> Coordinator { Coordinator(parent: self) }
|
||||
|
||||
@@ -55,10 +57,83 @@ struct MapViewRepresentable: UIViewRepresentable {
|
||||
animated: false
|
||||
)
|
||||
|
||||
// Center pin — positioned relative to geographic center, not Auto Layout
|
||||
let pinSize: CGFloat = 38
|
||||
let sizeCfg = UIImage.SymbolConfiguration(pointSize: pinSize, weight: .semibold)
|
||||
let paletteCfg = UIImage.SymbolConfiguration(paletteColors: [.white, .red])
|
||||
let pinImage = UIImage(systemName: "mappin",
|
||||
withConfiguration: sizeCfg.applying(paletteCfg))
|
||||
let pin = UIImageView(image: pinImage)
|
||||
pin.frame = CGRect(x: 0, y: 0, width: pinSize, height: pinSize)
|
||||
pin.isUserInteractionEnabled = false
|
||||
pin.layer.shadowColor = UIColor.black.cgColor
|
||||
pin.layer.shadowOpacity = 0.28
|
||||
pin.layer.shadowRadius = 5
|
||||
pin.layer.shadowOffset = CGSize(width: 0, height: 3)
|
||||
map.addSubview(pin)
|
||||
context.coordinator.centerPin = pin
|
||||
|
||||
// Zoom controls — UIKit subviews inside MKMapView, move with the map
|
||||
let zoomStack = UIStackView()
|
||||
zoomStack.axis = .vertical
|
||||
zoomStack.spacing = 0
|
||||
zoomStack.translatesAutoresizingMaskIntoConstraints = false
|
||||
zoomStack.alignment = .center
|
||||
zoomStack.backgroundColor = UIColor.systemBackground.withAlphaComponent(0.8)
|
||||
zoomStack.layer.cornerRadius = 13
|
||||
zoomStack.layer.shadowColor = UIColor.black.cgColor
|
||||
zoomStack.layer.shadowOpacity = 0.18
|
||||
zoomStack.layer.shadowRadius = 7
|
||||
zoomStack.layer.shadowOffset = CGSize(width: 0, height: 3)
|
||||
|
||||
let zoomInBtn = UIButton(type: .system)
|
||||
zoomInBtn.setImage(UIImage(systemName: "plus", withConfiguration: UIImage.SymbolConfiguration(pointSize: 22, weight: .bold)), for: .normal)
|
||||
zoomInBtn.addTarget(context.coordinator, action: #selector(Coordinator.zoomInTapped), for: .touchUpInside)
|
||||
zoomInBtn.heightAnchor.constraint(equalToConstant: 52).isActive = true
|
||||
zoomInBtn.widthAnchor.constraint(equalToConstant: 52).isActive = true
|
||||
|
||||
let zoomLabel = UILabel()
|
||||
let roundedDesc = UIFont.systemFont(ofSize: 9, weight: .semibold).fontDescriptor.withDesign(.rounded)
|
||||
zoomLabel.font = roundedDesc.flatMap { UIFont(descriptor: $0, size: 9) } ?? .systemFont(ofSize: 9, weight: .semibold)
|
||||
zoomLabel.textColor = .secondaryLabel
|
||||
zoomLabel.textAlignment = .center
|
||||
zoomLabel.adjustsFontSizeToFitWidth = true
|
||||
zoomLabel.minimumScaleFactor = 0.7
|
||||
zoomLabel.text = MapZoomMath.viewportScaleLabel(distanceMeters: ViewportStore.loadOrDefault())
|
||||
zoomLabel.heightAnchor.constraint(equalToConstant: 28).isActive = true
|
||||
context.coordinator.zoomLabel = zoomLabel
|
||||
|
||||
let zoomOutBtn = UIButton(type: .system)
|
||||
zoomOutBtn.setImage(UIImage(systemName: "minus", withConfiguration: UIImage.SymbolConfiguration(pointSize: 22, weight: .bold)), for: .normal)
|
||||
zoomOutBtn.addTarget(context.coordinator, action: #selector(Coordinator.zoomOutTapped), for: .touchUpInside)
|
||||
zoomOutBtn.heightAnchor.constraint(equalToConstant: 52).isActive = true
|
||||
zoomOutBtn.widthAnchor.constraint(equalToConstant: 52).isActive = true
|
||||
|
||||
let sep1 = UIView(); sep1.translatesAutoresizingMaskIntoConstraints = false
|
||||
sep1.heightAnchor.constraint(equalToConstant: 0.5).isActive = true
|
||||
sep1.backgroundColor = .separator
|
||||
sep1.widthAnchor.constraint(equalToConstant: 28).isActive = true
|
||||
let sep2 = UIView(); sep2.translatesAutoresizingMaskIntoConstraints = false
|
||||
sep2.heightAnchor.constraint(equalToConstant: 0.5).isActive = true
|
||||
sep2.backgroundColor = .separator
|
||||
sep2.widthAnchor.constraint(equalToConstant: 28).isActive = true
|
||||
|
||||
zoomStack.addArrangedSubview(zoomInBtn)
|
||||
zoomStack.addArrangedSubview(sep1)
|
||||
zoomStack.addArrangedSubview(zoomLabel)
|
||||
zoomStack.addArrangedSubview(sep2)
|
||||
zoomStack.addArrangedSubview(zoomOutBtn)
|
||||
map.addSubview(zoomStack)
|
||||
NSLayoutConstraint.activate([
|
||||
zoomStack.leadingAnchor.constraint(equalTo: map.safeAreaLayoutGuide.leadingAnchor, constant: 16),
|
||||
zoomStack.topAnchor.constraint(equalTo: map.safeAreaLayoutGuide.topAnchor, constant: 130),
|
||||
])
|
||||
|
||||
let tap = UITapGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.handleTap(_:)))
|
||||
tap.cancelsTouchesInView = false
|
||||
map.addGestureRecognizer(tap)
|
||||
context.coordinator.map = map
|
||||
context.coordinator.setupKeyboardObservers()
|
||||
return map
|
||||
}
|
||||
|
||||
@@ -76,11 +151,41 @@ struct MapViewRepresentable: UIViewRepresentable {
|
||||
private var activeCommandIsZoom = false
|
||||
private var regionChangeWasUserDriven = false
|
||||
private var isPinchZoom = false
|
||||
weak var zoomLabel: UILabel?
|
||||
weak var centerPin: UIImageView?
|
||||
private let pinSize: CGFloat = 38
|
||||
// 蓝点实际大小从 MKUserLocationView 取,默认 20pt
|
||||
private var userDotDiameter: CGFloat = 20
|
||||
|
||||
@objc func zoomInTapped() { parent.onZoomIn?() }
|
||||
@objc func zoomOutTapped() { parent.onZoomOut?() }
|
||||
|
||||
init(parent: MapViewRepresentable) {
|
||||
self.parent = parent
|
||||
}
|
||||
|
||||
private func updatePinPosition(on mapView: MKMapView) {
|
||||
guard let pin = centerPin else { return }
|
||||
let centerPt = mapView.convert(mapView.centerCoordinate, toPointTo: mapView)
|
||||
// pin 尖在底边,上移自身一半 + 蓝点半径对齐
|
||||
pin.center = CGPoint(
|
||||
x: centerPt.x,
|
||||
y: centerPt.y - pinSize / 2 + 5
|
||||
)
|
||||
}
|
||||
|
||||
func setupKeyboardObservers() {
|
||||
let nc = NotificationCenter.default
|
||||
nc.addObserver(forName: UIResponder.keyboardWillShowNotification, object: nil, queue: .main) { [weak self] _ in
|
||||
guard let self, let map = self.map else { return }
|
||||
self.updatePinPosition(on: map)
|
||||
}
|
||||
nc.addObserver(forName: UIResponder.keyboardWillHideNotification, object: nil, queue: .main) { [weak self] _ in
|
||||
guard let self, let map = self.map else { return }
|
||||
self.updatePinPosition(on: map)
|
||||
}
|
||||
}
|
||||
|
||||
func consume(_ command: MapCameraCommand?, on map: MKMapView) {
|
||||
guard let command, command.id != lastConsumedCommandID else { return }
|
||||
lastConsumedCommandID = command.id
|
||||
@@ -111,6 +216,16 @@ struct MapViewRepresentable: UIViewRepresentable {
|
||||
parent.onMapTap(map.convert(point, toCoordinateFrom: map))
|
||||
}
|
||||
|
||||
func mapView(_ mapView: MKMapView, didAdd views: [MKAnnotationView]) {
|
||||
for view in views where view.annotation is MKUserLocation {
|
||||
// 取蓝点实际大小,隐藏精度圈
|
||||
userDotDiameter = view.bounds.width
|
||||
for sub in view.subviews where sub.bounds.width > userDotDiameter + 4 {
|
||||
sub.isHidden = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mapView(_ mapView: MKMapView, didUpdate userLocation: MKUserLocation) {
|
||||
guard let location = userLocation.location,
|
||||
CLLocationCoordinate2DIsValid(location.coordinate),
|
||||
@@ -143,6 +258,16 @@ struct MapViewRepresentable: UIViewRepresentable {
|
||||
func mapView(_ mapView: MKMapView, regionDidChangeAnimated animated: Bool) {
|
||||
let distance = visibleVerticalDistance(in: mapView)
|
||||
parent.onViewportChanged(distance)
|
||||
zoomLabel?.text = MapZoomMath.viewportScaleLabel(distanceMeters: distance)
|
||||
updatePinPosition(on: mapView)
|
||||
// 每次地图区域变化时更新瓦片坐标系类型
|
||||
let c = mapView.centerCoordinate
|
||||
CoordinateConverter.updateTileType(lat: c.latitude, lon: c.longitude)
|
||||
// 同步蓝点坐标(避免 delegate 更新不及时导致 mapState.realtimeLocation 为 nil)
|
||||
if let ul = mapView.userLocation.location,
|
||||
CLLocationCoordinate2DIsValid(ul.coordinate), ul.horizontalAccuracy >= 0 {
|
||||
parent.onRealtimeLocationChanged(ul)
|
||||
}
|
||||
|
||||
let userZoomed: Bool
|
||||
if activeCameraCommandID != nil {
|
||||
|
||||
@@ -25,6 +25,9 @@ final class ProxyManager: ObservableObject {
|
||||
let lon = settings.flatMap { $0.enabled ? $0.longitude : nil } ?? 0
|
||||
let enabled = (settings?.enabled ?? false) ? CInt(1) : CInt(0)
|
||||
let accuracy = CInt(settings?.accuracy ?? 25)
|
||||
RuntimeLogger.info("APP", "坐标转换", "启动代理: WGS-84", details: [
|
||||
"lat": String(lat), "lon": String(lon)
|
||||
])
|
||||
let result: UInt = authority.certPEM.withCString { cp in
|
||||
authority.keyPEM.withCString { kp in
|
||||
UInt(wloccore_startproxy(UnsafeMutablePointer(mutating: cp), UnsafeMutablePointer(mutating: kp), CDouble(lat), CDouble(lon), enabled, accuracy))
|
||||
|
||||
@@ -61,7 +61,7 @@ final class RealtimeLocationManager: NSObject, ObservableObject, CLLocationManag
|
||||
private let driver: RealtimeLocationDriving
|
||||
private let oneShotTimeoutNanoseconds: UInt64
|
||||
private let fallbackTimeoutNanoseconds: UInt64
|
||||
private let cacheMaxAge: TimeInterval
|
||||
private let cacheMaxAge: TimeInterval = 20
|
||||
private var nextRequestID: UInt64 = 0
|
||||
private var activeRequest: ActiveRequest?
|
||||
private var timeoutTask: Task<Void, Never>?
|
||||
@@ -69,13 +69,11 @@ final class RealtimeLocationManager: NSObject, ObservableObject, CLLocationManag
|
||||
init(
|
||||
driver: RealtimeLocationDriving,
|
||||
oneShotTimeoutNanoseconds: UInt64 = 1_500_000_000,
|
||||
fallbackTimeoutNanoseconds: UInt64 = 5_000_000_000,
|
||||
cacheMaxAge: TimeInterval = 20
|
||||
fallbackTimeoutNanoseconds: UInt64 = 5_000_000_000
|
||||
) {
|
||||
self.driver = driver
|
||||
self.oneShotTimeoutNanoseconds = oneShotTimeoutNanoseconds
|
||||
self.fallbackTimeoutNanoseconds = fallbackTimeoutNanoseconds
|
||||
self.cacheMaxAge = cacheMaxAge
|
||||
authorizationStatus = driver.authorizationStatus
|
||||
super.init()
|
||||
driver.delegate = self
|
||||
@@ -105,11 +103,6 @@ final class RealtimeLocationManager: NSObject, ObservableObject, CLLocationManag
|
||||
|
||||
if let cached = freshestCachedLocation() {
|
||||
location = cached
|
||||
RuntimeLogger.info("APP", "定位", "使用系统缓存实时定位", details: [
|
||||
"age": String(format: "%.2f", max(0, -cached.timestamp.timeIntervalSinceNow)),
|
||||
"lat": String(cached.coordinate.latitude),
|
||||
"lon": String(cached.coordinate.longitude)
|
||||
])
|
||||
return cached.coordinate
|
||||
}
|
||||
|
||||
|
||||
@@ -54,15 +54,8 @@ final class SetupCoordinator: ObservableObject {
|
||||
message = "代理链路异常,未收到响应"
|
||||
return
|
||||
}
|
||||
// 走同一套改写验证:Go Core 内模拟 Apple 响应并确认坐标被改写
|
||||
let patchResult = CoreBridge.testWlocPatch(lat: testLat, lon: testLon, accuracy: testAccuracy)
|
||||
if patchResult.hasPrefix("ok:") {
|
||||
trustState = .trusted
|
||||
message = "✓ 定位环境正常(返回 \(status))"
|
||||
} else {
|
||||
trustState = .unavailable
|
||||
message = "定位数据改写验证失败:\(patchResult)"
|
||||
}
|
||||
} catch {
|
||||
trustState = .unavailable
|
||||
let ns = error as NSError
|
||||
@@ -107,7 +100,6 @@ final class SetupCoordinator: ObservableObject {
|
||||
log("======== 代理验证测试 ========")
|
||||
log("App 版本: \(appVersion)")
|
||||
log("系统版本: iOS \(UIDevice.current.systemVersion)")
|
||||
log("测试目标: lat=\(testLat), lon=\(testLon)")
|
||||
log("")
|
||||
|
||||
// Step A: Proxy running
|
||||
@@ -126,18 +118,6 @@ final class SetupCoordinator: ObservableObject {
|
||||
}
|
||||
collectProxyLogs(since: stepAStart, to: log)
|
||||
|
||||
// Verification must never overwrite a newer location action.
|
||||
let previousCoordinates = proxy.coordinateSnapshot(
|
||||
accuracy: WlocSettingsStore.load()?.accuracy ?? 25
|
||||
)
|
||||
var verificationCoordinateRevision: UInt64?
|
||||
defer {
|
||||
if let revision = verificationCoordinateRevision {
|
||||
let restored = proxy.restoreCoords(previousCoordinates, ifUnchangedSince: revision)
|
||||
log(restored ? " ↩ 已恢复验证前的代理坐标" : " ↩ 检测到更新位置,跳过旧坐标恢复")
|
||||
}
|
||||
}
|
||||
|
||||
// Step B: Combined CA + WiFi proxy check (single request)
|
||||
log("")
|
||||
log("[步骤 B] 检测证书与 WiFi 代理…")
|
||||
@@ -181,38 +161,6 @@ final class SetupCoordinator: ObservableObject {
|
||||
}
|
||||
collectProxyLogs(since: stepBStart, to: log)
|
||||
|
||||
// Step C: Write and verify coordinates
|
||||
log("[步骤 C] 写入测试坐标并验证…")
|
||||
guard let testCoordinateRevision = proxy.setCoordsIfUnchanged(
|
||||
lat: testLat,
|
||||
lon: testLon,
|
||||
enabled: true,
|
||||
accuracy: 25,
|
||||
expectedRevision: previousCoordinates.revision
|
||||
) else {
|
||||
log(" ↪ 检测到更新位置,取消过期验证坐标写入")
|
||||
return .verificationSuperseded
|
||||
}
|
||||
verificationCoordinateRevision = testCoordinateRevision
|
||||
let coords = proxy.getCoords()
|
||||
if coords.enabled && abs(coords.lat - testLat) < 0.001 && abs(coords.lon - testLon) < 0.001 {
|
||||
log(" ✓ 坐标写入成功: lat=\(coords.lat) lon=\(coords.lon)")
|
||||
} else {
|
||||
log(" ✗ 坐标验证失败: enabled=\(coords.enabled) lat=\(coords.lat) lon=\(coords.lon)")
|
||||
return .coordinateWriteFailed("写入后回读不一致")
|
||||
}
|
||||
|
||||
// Step D: verify data rewriting via Go test patch
|
||||
log("[步骤 D] 验证定位数据改写…")
|
||||
let result = CoreBridge.testWlocPatch(lat: testLat, lon: testLon, accuracy: 25)
|
||||
log(" \(result)")
|
||||
if result.hasPrefix("ok:") {
|
||||
log(" ✓ 定位数据改写验证通过")
|
||||
} else {
|
||||
log(" ✗ 定位数据改写失败")
|
||||
return .patchFailed(result)
|
||||
}
|
||||
|
||||
log("")
|
||||
log("======== 环境检测通过 ✓ ========")
|
||||
return .success
|
||||
|
||||
@@ -45,15 +45,23 @@
|
||||
<table>
|
||||
<tr>
|
||||
<th>应用主界面</th>
|
||||
<th>钉钉</th>
|
||||
<th>微信</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="images/主界面.jpg" alt="iOS 虚拟定位应用主界面" width="180"></td>
|
||||
<td><img src="images/钉钉.jpg" alt="钉钉虚拟定位打卡" width="180"></td>
|
||||
<td><img src="images/微信.jpg" alt="微信虚拟定位" width="180"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Apple 地图</th>
|
||||
<th>高德地图</th>
|
||||
<th>Apple Watch</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="images/主界面.jpg" alt="iOS 虚拟定位应用主界面" width="210"></td>
|
||||
<td><img src="images/Apple%20Map.jpg" alt="iPhone Location Spoofer Apple Maps 效果" width="210"></td>
|
||||
<td><img src="images/高德地图.jpg" alt="Fake GPS 高德地图定位效果" width="210"></td>
|
||||
<td><img src="images/高血压.jpg" alt="Apple Watch 地区功能验证" width="210"></td>
|
||||
<td><img src="images/Apple%20Map.jpg" alt="Apple Maps 定位效果" width="180"></td>
|
||||
<td><img src="images/高德地图.jpg" alt="高德地图定位效果" width="180"></td>
|
||||
<td><img src="images/高血压.jpg" alt="Apple Watch 地区功能验证" width="180"></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
||||
@@ -6,5 +6,7 @@
|
||||
<array>
|
||||
<string>group.com.paopaolabs.location-spoofer</string>
|
||||
</array>
|
||||
<key>com.apple.developer.networking.wifi-info</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -14,5 +14,7 @@
|
||||
</array>
|
||||
<key>get-task-allow</key>
|
||||
<true/>
|
||||
<key>com.apple.developer.networking.wifi-info</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -2,26 +2,105 @@ import Foundation
|
||||
|
||||
/// GCJ-02 (火星坐标) ↔ WGS-84 坐标转换。
|
||||
///
|
||||
/// 在中国地区,MKMapView 使用高德 (AutoNavi) 瓦片数据(GCJ-02 坐标系),
|
||||
/// 因此从 MKMapView 的 `centerCoordinate`、`convert(point:toCoordinateFrom:)`
|
||||
/// 等方法返回的坐标也是 GCJ-02。但 CoreLocation / CLLocationManager 返回的
|
||||
/// 以及 Apple wloc 定位服务使用的都是 WGS-84。
|
||||
///
|
||||
/// 虚拟定位的坐标流中:
|
||||
/// - 地图 UI 层(显示、选点):GCJ-02(与瓦片一致)
|
||||
/// - 代理写出层(wloc 响应改写):WGS-84
|
||||
///
|
||||
/// 因此需要在坐标从地图 UI 进入代理之前做 GCJ-02 → WGS-84 转换。
|
||||
/// MKMapView 根据实时定位动态切换瓦片源:中国境内用高德 GCJ-02,境外用 Apple WGS-84。
|
||||
/// App 内部统一以 WGS-84 存储,仅在地图交互时按当前瓦片类型双向转换。
|
||||
enum CoordinateConverter {
|
||||
// 椭球参数 (Krasovsky 1940)
|
||||
private static let a = 6378245.0
|
||||
private static let ee = 0.00669342162296594323
|
||||
|
||||
/// 坐标类型
|
||||
enum CoordType: String {
|
||||
case gcj02 = "GCJ-02"
|
||||
case wgs84 = "WGS-84"
|
||||
}
|
||||
|
||||
// MARK: - 全局瓦片类型
|
||||
|
||||
/// 当前地图瓦片坐标系(每次从地图拿到坐标后更新)
|
||||
@MainActor static var currentTileType = CoordType.gcj02
|
||||
|
||||
/// 从地图坐标推算当前瓦片类型并更新全局状态
|
||||
@MainActor
|
||||
static func updateTileType(lat: Double, lon: Double) {
|
||||
let type = detectType(lat: lat, lon: lon)
|
||||
if type != currentTileType {
|
||||
currentTileType = type
|
||||
RuntimeLogger.info("APP", "坐标转换", "地图瓦片切换 → \(type.rawValue)", details: [
|
||||
"坐标": "\(lat), \(lon)"
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 存取转换
|
||||
|
||||
/// 地图坐标 → WGS-84 存储
|
||||
@MainActor
|
||||
static func toStored(lat: Double, lon: Double) -> (lat: Double, lon: Double) {
|
||||
updateTileType(lat: lat, lon: lon)
|
||||
guard currentTileType == .gcj02 else {
|
||||
RuntimeLogger.info("APP", "坐标转换", "toStored: WGS-84 → 不转", details: [
|
||||
"lat": String(lat), "lon": String(lon)
|
||||
])
|
||||
return (lat, lon)
|
||||
}
|
||||
let wgs = gcj02ToWgs84(lat: lat, lon: lon)
|
||||
let d = distance(lat1: lat, lon1: lon, lat2: wgs.lat, lon2: wgs.lon)
|
||||
RuntimeLogger.info("APP", "坐标转换", "toStored: GCJ-02 → WGS-84", details: [
|
||||
"原始": "\(lat), \(lon)",
|
||||
"结果": "\(wgs.lat), \(wgs.lon)",
|
||||
"偏移": String(format: "%.0fm", d)
|
||||
])
|
||||
return wgs
|
||||
}
|
||||
|
||||
/// WGS-84 存储 → 当前地图瓦片坐标系(显示用)
|
||||
@MainActor
|
||||
static func toDisplay(lat: Double, lon: Double) -> (lat: Double, lon: Double) {
|
||||
guard currentTileType == .gcj02 else {
|
||||
RuntimeLogger.info("APP", "坐标转换", "toDisplay: WGS-84 → 不转", details: [
|
||||
"lat": String(lat), "lon": String(lon)
|
||||
])
|
||||
return (lat, lon)
|
||||
}
|
||||
let gcj = wgs84ToGcj02(lat: lat, lon: lon)
|
||||
let d = distance(lat1: lat, lon1: lon, lat2: gcj.lat, lon2: gcj.lon)
|
||||
RuntimeLogger.info("APP", "坐标转换", "toDisplay: WGS-84 → GCJ-02", details: [
|
||||
"原始": "\(lat), \(lon)",
|
||||
"结果": "\(gcj.lat), \(gcj.lon)",
|
||||
"偏移": String(format: "%.0fm", d)
|
||||
])
|
||||
return gcj
|
||||
}
|
||||
|
||||
// MARK: - 类型检测
|
||||
|
||||
/// Haversine 距离(米)
|
||||
static func distance(lat1: Double, lon1: Double, lat2: Double, lon2: Double) -> Double {
|
||||
let r = 6371000.0
|
||||
let dLat = (lat2 - lat1) * .pi / 180.0
|
||||
let dLon = (lon2 - lon1) * .pi / 180.0
|
||||
let a = sin(dLat / 2) * sin(dLat / 2)
|
||||
+ cos(lat1 * .pi / 180.0) * cos(lat2 * .pi / 180.0)
|
||||
* sin(dLon / 2) * sin(dLon / 2)
|
||||
return r * 2 * atan2(sqrt(a), sqrt(1 - a))
|
||||
}
|
||||
|
||||
/// MKMapView 瓦片坐标系:中国境内 GCJ-02,境外 WGS-84
|
||||
static func detectType(lat: Double, lon: Double) -> CoordType {
|
||||
// GCJ-02 加密只在中国境内生效,用地理边界判断
|
||||
if lat > 17.5 && lat < 54.0 && lon > 72.5 && lon < 136.0 {
|
||||
return .gcj02
|
||||
}
|
||||
return .wgs84
|
||||
}
|
||||
|
||||
// MARK: - 核心转换
|
||||
|
||||
/// GCJ-02 → WGS-84(迭代法,精度优于 0.5 米)
|
||||
static func gcj02ToWgs84(lat: Double, lon: Double) -> (lat: Double, lon: Double) {
|
||||
var wgsLat = lat
|
||||
var wgsLon = lon
|
||||
// 两次迭代足以收敛到亚米级精度
|
||||
for _ in 0..<2 {
|
||||
let d = delta(lat: wgsLat, lon: wgsLon)
|
||||
wgsLat = lat - d.lat
|
||||
@@ -30,6 +109,14 @@ enum CoordinateConverter {
|
||||
return (wgsLat, wgsLon)
|
||||
}
|
||||
|
||||
/// WGS-84 → GCJ-02
|
||||
static func wgs84ToGcj02(lat: Double, lon: Double) -> (lat: Double, lon: Double) {
|
||||
let d = delta(lat: lat, lon: lon)
|
||||
return (lat + d.lat, lon + d.lon)
|
||||
}
|
||||
|
||||
// MARK: - 内部
|
||||
|
||||
/// 计算偏移量 (WGS-84 → GCJ-02 的增量)
|
||||
private static func delta(lat: Double, lon: Double) -> (lat: Double, lon: Double) {
|
||||
let dLat = transformLat(x: lon - 105.0, y: lat - 35.0)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Network
|
||||
import Foundation
|
||||
import SystemConfiguration.CaptiveNetwork
|
||||
|
||||
@MainActor
|
||||
final class NetworkMonitor: ObservableObject {
|
||||
@@ -7,20 +8,58 @@ final class NetworkMonitor: ObservableObject {
|
||||
|
||||
@Published private(set) var isSatisfied = true
|
||||
@Published private(set) var isWiFiEnabled = true
|
||||
@Published private(set) var currentSSID: String?
|
||||
|
||||
/// WiFi 重连或 SSID 变化时触发(仅虚拟定位激活时使用)
|
||||
var onWiFiChanged: (() -> Void)?
|
||||
|
||||
private let monitor = NWPathMonitor()
|
||||
private var ssidTimer: Timer?
|
||||
private var wasSatisfied = true
|
||||
|
||||
private init() {
|
||||
monitor.pathUpdateHandler = { [weak self] path in
|
||||
let satisfied = path.status == .satisfied
|
||||
let wifi = path.usesInterfaceType(.wifi)
|
||||
Task { @MainActor in
|
||||
self?.isSatisfied = satisfied
|
||||
self?.isWiFiEnabled = wifi
|
||||
guard let self else { return }
|
||||
// 网络恢复连接 → 触发检测
|
||||
let reconnected = satisfied && !self.wasSatisfied && wifi
|
||||
self.wasSatisfied = satisfied
|
||||
self.isSatisfied = satisfied
|
||||
self.isWiFiEnabled = wifi
|
||||
if reconnected {
|
||||
self.onWiFiChanged?()
|
||||
}
|
||||
}
|
||||
}
|
||||
monitor.start(queue: .main)
|
||||
startSSIDPolling()
|
||||
}
|
||||
|
||||
var isAirplaneMode: Bool { !isSatisfied }
|
||||
|
||||
private func startSSIDPolling() {
|
||||
ssidTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { [weak self] _ in
|
||||
Task { @MainActor in
|
||||
guard let self else { return }
|
||||
let ssid = Self.fetchSSID()
|
||||
if ssid != self.currentSSID, ssid != nil {
|
||||
self.currentSSID = ssid
|
||||
self.onWiFiChanged?()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func fetchSSID() -> String? {
|
||||
guard let interfaces = CNCopySupportedInterfaces() as? [String] else { return nil }
|
||||
for iface in interfaces {
|
||||
if let info = CNCopyCurrentNetworkInfo(iface as CFString) as? [String: Any],
|
||||
let ssid = info[kCNNetworkInfoKeySSID as String] as? String {
|
||||
return ssid
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 374 KiB After Width: | Height: | Size: 347 KiB |
|
Before Width: | Height: | Size: 280 KiB After Width: | Height: | Size: 274 KiB |
|
After Width: | Height: | Size: 292 KiB |
|
After Width: | Height: | Size: 161 KiB |
|
Before Width: | Height: | Size: 913 KiB After Width: | Height: | Size: 317 KiB |