Last updated: 2026-07-29 KST
Phase 4 is complete for the previous signed private-dogfood architecture. The
next active milestone is the owner-approved Windows/macOS parity pass documented
in docs/macos-next-session.md and docs/platform-parity-contract.md.
The signed DNS Proxy spike proved native UDP/TCP forwarding, XPC observations, simultaneous Packet Tunnel operation, TTL-bounded dynamic IPv4 route updates, and clean teardown on the previous development Mac. The checked-in consumer Connect flow still uses static pre-resolution as its supported path, but that is now an implementation gap rather than the intended final architecture. General encrypted-DNS compatibility, Developer ID distribution provisioning, and public distribution remain unverified.
Owner direction changed on 2026-07-28 after the Windows/macOS parity review.
docs/platform-parity-contract.md now defines the target: promote the proven DNS
Proxy path to the supported macOS consumer connection flow, keep the UI in
Connecting until dynamic DNS ownership and target AAAA filtering are proven,
and fail cleanly instead of silently reporting a static-only connection as
equivalent to Windows. This is a committed product direction, not a claim that a
new signed consumer build has already passed.
The owner also prefers the Windows UI. The next macOS UI pass should retain SwiftUI, resizable layouts, keyboard/VoiceOver support, contrast, and appearance choices while adopting the Windows dashboard hierarchy, compact navigation, plain-language status, restrained light surfaces, coral accent, and lower diagnostic density.
- Move DNS Proxy activation and health from Debug diagnostics into the consumer connection coordinator without exposing raw DNS data.
- Keep connection state pending until owned preference, provider XPC readiness, target publication, dynamic observation, and target AAAA filtering pass.
- On any failed prerequisite, disable only the owned DNS Proxy, stop the Packet Tunnel, remove VPN Router routes, and report the failed stage.
- Preserve the proven five-minute refresh, fifteen-minute rotating-answer retention, 512-route bound, and second-VPN tunnel-interface fail-safe.
- Align Home, Profiles, Sites, Troubleshooting, and Settings with the Windows information hierarchy while retaining native macOS interaction.
- Run a new signed real-Mac parity matrix covering UDP/TCP A and AAAA, rotating CDN routes, default/control routing, ownership loss, Tailscale transition, Host relaunch, provider termination, sleep/wake, network change, disconnect, and restart recovery.
- Do not claim public parity until Developer ID distribution provisioning and a clean optimized Release archive also pass. The clean optimizer gate was later completed on 2026-07-30; signing and runtime gates remain.
The first parity implementation group is complete in source and automated tests:
ConsumerConnectionCoordinatornow owns the ordered consumer state machine frompreflightingthroughready, plusdisconnectingand stablefailed(stage, code)results.- The supported Connect action validates the selected profile/sites and
encrypted-DNS preconditions before mutation, prepares and starts Packet Tunnel,
enables only VPN Router's DNS Proxy, publishes the expanded targets through
the bounded XPC retry path, verifies owned preference/provider readiness, and
arms the active-tunnel-set safety prerequisite before exposing
Connected. - A manual confirmation is required when browser policy or Private Relay state cannot be verified through supported APIs. A blocking browser DoH policy stops before Packet Tunnel mutation.
- Failure after a Packet Tunnel or DNS Proxy start attempt cleans up in reverse ownership order: VPN Router's owned DNS Proxy first, then Packet Tunnel. The injected tests cover preflight, preparation, Packet Tunnel start, DNS Proxy enablement, target publication, provider verification, and safety-monitor arming failures.
- Host relaunch no longer presents a connected Packet Tunnel as a complete consumer connection until owned DNS Proxy target/XPC readiness is restored. A connected static-only orphan is stopped through Network Extension instead of being reported as parity-safe.
- Five-minute static refresh, 15-second observation refresh, and the DNS Proxy
ownership/tunnel-interface monitors now run only after the coordinator reaches
ready. - Troubleshooting schema version 2 adds only the bounded coordinator stage and stable failure code. It still has no raw domains, addresses, DNS payloads, configuration text, keys, or unrestricted provider messages.
The continuation environment was unchanged: macOS 26.5.2, arm64, Xcode 26.6,
macOS SDK 26.5, and Swift 6.3.3. The pre-change baseline passed all 48 focused
checks. The new suite passes 51 checks, including a seven-case parameterized
failure matrix and disconnect ordering. An unsigned arm64/macOS 15 Debug build
of the Host, Packet Tunnel, and DNS Proxy targets succeeds. The build reused the
previously prepared WireGuard Go archive; the recorded LC_DYSYMTAB warning
remains a release limitation, and signed Network Extension behavior is not
claimed from this build.
Next: exercise the new atomic consumer sequence in an owner-signed build, promote fresh DNS Proxy system-extension approval from developer diagnostics into a consumer prerequisite, then begin the Windows-referenced five-screen UI pass.
Captive-portal discovery, sign-in, and recovery are explicitly outside the
v0.1.0 release scope by owner decision on 2026-07-28. VPN Router must not be
used to perform portal authentication; the user completes the portal with VPN
Router disconnected, then connects VPN Router only after ordinary internet access
works. No captive-network detection, bypass, or automatic network mutation will
be added for this release.
The Phase 4 technical baseline and remediation audit are recorded in
docs/macos-phase4-quality-audit.md. The initial score was 12/20 with no P0
blockers and five P1 findings. The automated remediation pass raises it to 17/20;
the completed signed remediation and acceptance pass raises it to 18/20. Public
distribution still required a clean Release optimizer result without the
documented Xcode 26.6 workaround at that checkpoint. The gate was subsequently
closed on 2026-07-30.
The automated Phase 4 implementation and owner-operated signed UI/lifecycle
matrix are complete. Public Developer ID distribution remains intentionally
blocked; 0.1.0 is approved only as a signed private Apple Silicon dogfood build.
The connected-status VoiceOver announcement was owner-skipped and remains
explicitly unverified.
Post-Phase-4 Windows-parity work has started. Static media expansion now resolves
the concrete www.youtube.com and www.netflix.com hosts in addition to the
existing roots and CDN families. The host also keeps distinct IPv4 answers from
the most recent fifteen minutes across five-minute static refreshes, removes them
at their original expiry, resets the history when the expanded domain set changes,
and retains the existing 512-route limit. This narrows the rotating-answer gap but
does not replace browser DNS observation for arbitrary CDN subdomains. The focused
test suite now passes 38 checks and an unsigned arm64 Debug host/extension build
succeeds.
The signed acceptance run passed on 2026-07-28 KST. The fresh connection applied
46 routes to utun13; all eight current www.youtube.com answers and both current
www.netflix.com answers used that interface while the default route and a control
address remained on en6. During the five-minute observation window the route
count increased to 49 without disconnecting. Real HTTPS requests to both www
hosts returned HTTP 200, YouTube reported GL=JP, and Netflix reported country
JP with Japan locale markers. Normal disconnect completed in about one second,
removed every utun13 IPv4 route, kept the default and control routes on en6,
and left ordinary HTTPS working.
This closes the private-dogfood streaming acceptance item for the concrete web entry points. Arbitrary rotating media/CDN subdomains still require the proven DNS Proxy observation path before macOS can claim Windows-level dynamic coverage.
The next DNS Proxy safety pass is implemented and awaits signed verification. Diagnostic activation now requires an already-connected VPN Router Packet Tunnel. While both providers are active, the Host checks the owned DNS Proxy preference and XPC provider health every five seconds. Loss of ownership or enablement fails safe immediately; three consecutive single-attempt XPC health windows fail safe after roughly fifteen to twenty-one seconds. The fail-safe stops only VPN Router's Packet Tunnel and disables the DNS Proxy only when its bundle identifier still proves VPN Router ownership. Normal disconnect disables the owned DNS Proxy before stopping the tunnel, and a Host relaunch disables an owned orphan DNS Proxy when no Packet Tunnel is connected. Other providers are never changed. Three focused policy tests cover runtime-state classification and the transient-failure threshold; the full suite now passes 41 checks and the unsigned arm64 Debug app/extension build succeeds.
The first signed safety-pass attempt on 2026-07-28 did not pass dynamic-route
acceptance. Packet Tunnel and DNS Proxy were simultaneously active with 41-42
utun13 routes, normal default/control routing, and existing AdGuard and Tailscale
extensions left unchanged. New selected CDN answers remained on the primary
interface after multiple twenty-second windows, so the run was stopped rather
than claiming dynamic coverage. Normal stop removed all utun13 routes and left
DNS and HTTPS healthy. Investigation also found that using the retrying diagnostic
snapshot for each five-second health poll could extend the intended failure
window; the monitor now uses a single-attempt two-second XPC health check. A new
signed build and the in-app aggregate observation summary are required to
distinguish provider observation from Host route-application failure.
The rebuilt signed run then passed dynamic-route operation. The app reported 13
DNS-observed routes merged into 41 static routes for 54 total, and continued
refreshing to 63 system utun13 routes while the tunnel stayed connected and the
default/control routes remained on the primary interface. Fresh YTImg and GGpht
addresses moved from the primary interface to utun13. Directly pausing the
root-owned DNS Proxy process was correctly rejected by macOS without changing its
state, so that attempt is not fail-safe evidence. A Debug-only, ownership-checked
test action now disables only VPN Router's own DNS Proxy preference without
updating the Host controller state; the five-second monitor must detect that real
preference-state loss and disconnect the Packet Tunnel in the next signed run.
The signed preference-loss test passed on 2026-07-28. After the Debug-only action
disabled the owned DNS Proxy preference without updating Host state, the monitor
detected the loss and the VPN was already Disconnected at the first external
check. All VPN Router utun routes were gone, the default and control routes
remained on en6, and normal DNS plus HTTPS succeeded. The system-extension
process remaining resident is macOS lifecycle behavior and did not retain an
enabled configuration or VPN routes. This completes the private-dogfood DNS Proxy
ownership/lifecycle fail-safe gate.
Windows-parity IPv6 protection is now implemented for the DNS Proxy path and
awaits signed verification. UDP and TCP DNS responses for saved or media-expanded
target domains are inspected after upstream resolution; matching AAAA responses
are replaced with an empty successful response that preserves the transaction and
question, while A responses and unrelated DNS payloads remain unchanged. TCP
length framing and split response delivery are preserved. Only an aggregate
aaaaResponseFiltered counter is retained. Two focused parser tests cover target
filtering and non-target/A pass-through; the full suite now passes 43 checks and
the unsigned app, Packet Tunnel, and DNS Proxy build succeeds.
The signed build-12 IPv6 protection check passed on 2026-07-28 after installing
the containing app in /Applications and activating the embedded build-12 system
extension. UDP AAAA queries for www.youtube.com, www.netflix.com, and
i.ytimg.com returned empty answers; forced TCP AAAA queries for both www hosts
also returned empty answers. An unrelated example.com AAAA query still returned
two IPv6 answers. Target A answers remained available and all tested YouTube and
Netflix IPv4 addresses used utun13, while the default route remained on en6.
Normal stop completed in about one second, removed every utun13 route, and left
unrelated AAAA, DNS, and HTTPS healthy. The filter currently protects only the
explicitly enabled DNS Proxy path; static pre-resolution mode still reports IPv6
bypass risk rather than claiming protection.
The next encrypted-DNS conflict preflight is implemented. Before the diagnostic
DNS Proxy can be enabled, the Host now reads only the effective macOS
DnsOverHttpsMode preference for installed Chrome and Edge. automatic,
secure, and unknown configured values block activation; explicit off passes;
and an absent policy requires manual browser verification. The check runs again
inside the confirmation action to reduce stale-state risk. It never writes a
browser preference. Because the app does not use an unsupported private API to
infer iCloud Private Relay state, the UI also requires the owner to verify the
current network's “Limit IP Address Tracking” setting manually and does not
change it. Four focused policy tests cover normalization and allow/warn/block
decisions; all 47 core checks and the unsigned arm64 Debug host/extensions build
pass.
Signed build-13 verification passed on 2026-07-28. The owner activated the
embedded build-13 system extension, manually checked Chrome Secure DNS and the
current network's Private Relay setting after the Host reported that Chrome had
no explicit DnsOverHttpsMode policy, and then enabled the diagnostic DNS Proxy.
The Packet Tunnel and DNS Proxy remained active across the ownership-monitor and
15-second refresh windows. The primary default stayed on en6, the dynamic
utun13 route count remained between 64 and 65, all ten current YouTube/Netflix
IPv4 answers used the tunnel, target UDP and TCP AAAA answers remained empty, and
an unrelated control retained two AAAA answers. Control DNS and YouTube/Netflix
HTTPS also passed. This verifies the signed warning/confirmation path, but does
not turn an absent browser policy or Private Relay's non-public state into an
automatically verified condition.
The first actually connected second-VPN coexistence run failed safely on
2026-07-28. With VPN Router's Packet Tunnel and DNS Proxy active, the owner
connected Tailscale without an exit node. macOS kept separate route tables
(utun13 with 62 VPN Router routes and utun16 with seven Tailscale routes), the
default and control route stayed on en6, and all ten tested target IPv4 answers
still used VPN Router. However, target AAAA answers became visible again (15 over
UDP and 11 over forced TCP), proving that the second VPN's DNS path bypassed the
DNS Proxy even though its preference, process, and XPC health remained active.
YouTube HTTPS also failed during the unsafe state. Testing stopped immediately:
only VPN Router was disconnected, all 62 utun13 routes were removed, Tailscale
remained connected, and control DNS/HTTPS stayed healthy. This is a coexistence
failure, not a pass. The next remediation must monitor real DNS-flow coverage
rather than treating provider liveness as proof that system queries still pass
through it.
Build 14's first remediation did not pass the transition retest. Starting
Tailscale before the DNS Proxy produced a stable, healthy coexistence state:
separate 52-route and seven-route tunnel interfaces, ten of ten target IPv4
answers on VPN Router, empty target UDP/TCP AAAA answers, control traffic on
en6, and HTTP 200 from both media sites. Disconnecting and reconnecting
Tailscale after the DNS Proxy was active again exposed 11 target AAAA answers
for at least 30 seconds, but the DNS-flow canary continued to reach the proxy
through a different resolver path and did not fail safe. VPN Router was manually
stopped again, its routes were removed, Tailscale stayed connected, and control
HTTPS remained healthy. The canary implementation has been removed rather than
retained as a misleading health signal.
Build 15 attempted a replacement remediation. Two seconds after DNS Proxy activation settled, the Host captured the active SystemConfiguration DNS dictionaries into an in-memory SHA-256 fingerprint. It never persists or displays resolver addresses. Any later DNS configuration change fails safe at the next five-second ownership window, with the intent of covering second-VPN and encrypted-DNS transitions conservatively. Later signed results below disproved this signal and the implementation was removed.
Build 15 detected a DNS configuration change, preserved Tailscale, removed every VPN Router route, and left control HTTPS healthy, but it armed only two seconds after DNS Proxy activation and therefore treated the proxy's own late-settling configuration as an external transition. The owner also reported that the Packet Tunnel connection itself took unusually long, which remains a separate latency observation for the next run. A read-only external hash check while VPN Router was disconnected then observed exactly one DNS fingerprint change when Tailscale disconnected and one when it reconnected, confirming that the selected system signal captures the required transition without exposing resolver contents.
Build 16 now waits for three identical one-second DNS fingerprint samples before arming the five-second transition gate, with a bounded twelve-sample startup window. Failure to obtain a stable baseline also disconnects safely. This should avoid the build-15 startup false positive while preserving detection of the two confirmed Tailscale transitions. Signed timing and transition verification is pending.
The build-16 signed startup retest still failed conservatively: the Packet Tunnel
itself connected faster than the external half-second sampler could observe, but
the DNS Proxy's system configuration changed again nine seconds after its process
started. The three-sample baseline had already armed, so VPN Router disconnected,
removed its routes, preserved Tailscale, kept the default on en6, and left
control HTTPS healthy. Build 17 therefore requires ten identical one-second
samples within a bounded thirty-sample window. The UI explicitly labels this
arming period and reports when ownership, XPC, and DNS-transition monitoring are
ready. A baseline that never stabilizes still fails safe.
Build 17 also failed conservatively immediately after reporting ready. Follow-up checks ruled out nondeterministic serialization: fifty identical-state reads produced one hash, and the Tailscale-only idle configuration changed zero times over twenty seconds. The remaining evidence points to a late DNS Proxy system settle just beyond the ten-second quiet window. Build 18 therefore requires twenty consecutive identical one-second samples within a bounded sixty-sample window. Any intervening change resets the count; that reset behavior now has a focused unit test, raising the suite to 49 checks. The longer arming delay is visible in the UI and still fails safe if no stable baseline is available.
Build 18 proved that extending the DNS quiet window is not a valid fix: it also reported ready and then immediately treated a DNS Proxy-owned system update as an external transition. VPN Router again removed its routes while Tailscale and control HTTPS remained healthy. The DNS-dictionary fingerprint and its stabilization helper have now been removed rather than weakened further.
Build 19 instead snapshots only the set of active, up utun interfaces that
currently have an IPv4 address, using getifaddrs. It stores no addresses,
routes, DNS values, provider names, or third-party configuration. The set is
checked every second, while existing ownership and XPC health checks remain on
their five-second cadence. DNS Proxy activation and route-count refreshes do not
create a tunnel interface; a second VPN disconnect/reconnect does. Any set change
or unreadable interface state fails safe immediately. The focused policy suite
returns to 48 passing checks after removing the disproven DNS stabilization test,
and the unsigned arm64 Debug host/extensions build completes without warnings.
Signed stable-state and Tailscale-transition validation is pending.
The first signed build-19 activation did not reach the interface-monitor test. The DNS Proxy preference and provider process started, but initial XPC target configuration failed before the provider was ready. The existing activation error path disabled the proxy and restored static routes while leaving the Packet Tunnel connected; external verification found 11 target AAAA answers, so testing manually stopped VPN Router while preserving Tailscale and healthy control HTTPS. Build 20 gives initial XPC setup up to twelve attempts with a half-second retry delay and the existing bounded two-second per-attempt timeout. If startup still fails, it now stops VPN Router and disables its owned DNS Proxy instead of returning to a connected static plan with known IPv6 bypass risk.
Signed build-20 validation passed on 2026-07-28. Initial XPC setup reached the
ready state with Tailscale already connected. During the stable coexistence
window, both VPN managers stayed connected, two IPv4 utun interfaces remained
active with seven Tailscale routes and 48 VPN Router routes, the default stayed
on en6, all ten target IPv4 answers used VPN Router, target UDP/TCP AAAA answers
were empty, the unrelated control retained two AAAA answers, and control,
YouTube, and Netflix HTTPS all returned 200. Seven additional five-second samples
kept both tunnel interfaces stable.
The owner then disconnected Tailscale for three seconds and reconnected it.
External one-second sampling observed the active IPv4 tunnel set change from two
to one; build 20 disconnected VPN Router about one second later. VPN Router's
previous route table fell to zero, Tailscale reconnected as the sole IPv4 tunnel,
the primary default remained on en6, and control DNS plus HTTPS passed. This
closes the actually connected second-VPN transition fail-safe check for the
tested Tailscale/no-exit-node configuration. It does not claim transparent
simultaneous recovery: any active tunnel-interface change intentionally requires
the owner to reconnect VPN Router and re-enable the diagnostic DNS Proxy after
the network is stable.
The macOS Xcode project has been generated at macos/VPNRouter/VPNRouter.xcodeproj
with a SwiftUI host app and an embedded stub Packet Tunnel extension. The host app
can install a stub NETunnelProviderManager configuration, request connect/disconnect,
derive display state from NEVPNStatus, and send a provider diagnostics message once
connected. The earlier JetBrains .idea/ files have been removed from the Git index
and ignored.
- Hardware: MacBook Pro (
MacBookPro18,2), Apple M1 Max, arm64, 10 cores, 64 GB RAM. - macOS: 26.5.2 (build 25F84).
- Xcode app: 26.6 (build 17F113) at
/Applications/Xcode.app. - macOS SDK in Xcode: 26.5.
- Active developer directory:
/Applications/Xcode.app/Contents/Developer. - Command Line Tools Swift: Apple Swift 6.3.3, targeting arm64 macOS 26.0.
- Xcode first-launch check: complete.
- Supported dogfood deployment target: macOS 15.0.
The active developer directory now points at Xcode, and an unqualified xcodebuild
successfully reports Xcode 26.6.
The Phase 4 private-dogfood baseline is macOS 15.0 on Apple Silicon. The release verification script overrides older local project defaults without modifying or committing the owner's signing settings.
- Apple Development or Mac Developer signing identities detected locally: none.
- Developer ID Application signing identity detected locally: present, but this does not confirm development provisioning for a Packet Tunnel extension.
- Xcode account/team configuration detected locally: none.
- Local provisioning profiles detected: none.
- Apple Developer Team ID: not confirmed; no value is recorded in this repository.
- Packet Tunnel entitlement/provisioning: not confirmed.
- The generated Xcode project currently contains local placeholder signing settings. Replace them in Xcode before Phase 0 exit; do not treat committed example values as approved product identifiers.
Packet Tunnel availability cannot be inferred from SDK presence, compilation, or a Developer ID distribution certificate. It must be confirmed with the owner's Apple Developer team by signing and launching an embedded Network Extension on this Mac.
These are documentation examples, not reserved production identifiers:
- Host app:
com.example.vpnrouter - Packet Tunnel extension:
com.example.vpnrouter.PacketTunnel - App Group:
group.com.example.vpnrouter.shared - Keychain access group suffix:
com.example.vpnrouter.shared
Before project generation, replace the com.example prefix with an owner-approved
reverse-DNS namespace. Keep the host and extension identifiers stable after signing
and provisioning begin.
The Phase 0 host app currently derives the provider bundle identifier as
<host bundle identifier>.PacketTunnel. If the owner chooses a different extension
suffix in Xcode, update TunnelIdentifiers.packetTunnelBundleIdentifier in
VPNRouter/ContentView.swift to match before testing the signed launch.
- Run
sw_vers,uname -m,xcodebuild -version, andswift --version. - Record Mac model/architecture, installed Xcode, SDK, and candidate deployment target.
- Confirm the Apple Developer Team ID without committing it.
- Confirm Packet Tunnel entitlement/provisioning availability.
- Propose example bundle identifiers without private team data.
- Confirm
git statusis clean before generating Xcode files. - Generate a minimal SwiftUI host app with an embedded stub Packet Tunnel extension.
- Sign and launch the host app on this Mac.
- Verify the extension reaches both
startTunnelandstopTunnelon this Mac.
Phase 0 signed launch evidence was owner-operated in Xcode on 2026-07-20. The owner reported that the host app launched, the stub tunnel configuration installed, connect/disconnect succeeded, and provider diagnostics were reachable. The private Team ID and owner-specific bundle identifiers are intentionally not recorded here.
Unsigned compile verification succeeds with code signing disabled:
xcodebuild -project /Users/netmd/project/vpn_router/macos/VPNRouter/VPNRouter.xcodeproj \
-scheme VPNRouter \
-configuration Debug \
-destination 'platform=macOS' \
-derivedDataPath /private/tmp/vpnrouter-xcode-derived \
CODE_SIGNING_ALLOWED=NO \
buildResult: ** BUILD SUCCEEDED **.
This confirms only that the SwiftUI host app and Packet Tunnel extension compile and
embed. The Packet Tunnel extension entitlement file has been narrowed to
packet-tunnel-provider only. This does not satisfy the signed Network Extension
launch gate.
Phase 1 parser/rule foundation compile verification also succeeds with code signing
disabled after adding the new Swift files to the VPNRouter Host App target:
xcodebuild -project /Users/netmd/project/vpn_router/macos/VPNRouter/VPNRouter.xcodeproj \
-scheme VPNRouter \
-configuration Debug \
-destination 'platform=macOS' \
-derivedDataPath /private/tmp/vpnrouter-xcode-derived-phase1c \
CODE_SIGNING_ALLOWED=NO \
buildResult: ** BUILD SUCCEEDED **.
A fake-key RunCodeSnippet check verified that:
DomainRuleExpanderexpands a YouTube rule to the expected related media domains;WireGuardConfigParser.prepareImportparses interface, DNS, peer, allowed IP, endpoint, and keepalive fields;- sanitized WireGuard text does not contain the fake private key and does contain
<stored securely>. KeychainSecretStorecan save, load, and delete a fake WireGuard private key using a temporary verification service name.ProfileStorecan save, load, upsert, and delete sanitized fake profile metadata in the app sandbox temporary directory.ProfileImportServicecan parse a fake WireGuard config, save its fake private key to Keychain, and persist only sanitized metadata to disk.DomainRoutePlannercan convert expanded fake domain rules and fake resolved IPv4 addresses into/32route descriptors while excluding unrelated domains.DomainRuleStoreandDomainRoutePlanServicecan persist fake selected domains and build a matching/32route plan from fake resolved IPv4 addresses.DomainRoutePlanServicecan resolve expanded selected domains through an injected resolver and build a route plan from the resolved IPv4 addresses.SystemDomainResolvernow matches the Windows pre-resolver behavior: individual domain lookup failures produce no IPv4 addresses for that domain instead of failing the whole route plan.- The macOS Sites UI now matches the Windows flow more closely with one-domain input, an add button, a saved domain list, and per-domain removal.
TunnelProfileConfigurationFactorycan build a safeNETunnelProviderProtocolprovider configuration from fake imported metadata and a fake route plan without embedding private key material.WireGuardKitis linked from the local package, andlibwg-go.acan be built for macOS arm64 into Xcode's Debug products directory using a temporary Go toolchain under/private/tmp.PacketTunnelProviderimportsWireGuardKit, owns aWireGuardAdapter, reports WireGuardKit linkage through diagnostics, and applies planned IPv4/32routes from provider configuration.- Host app and Packet Tunnel entitlements now use the same Keychain access group
and App Group.
KeychainSecretStorestores private keys in the entitlement's shared access group when available. PacketTunnelProvidercan rebuild a WireGuardKitTunnelConfigurationfrom sanitized provider configuration plus the shared Keychain private key, replace peer AllowedIPs with the selected/32route plan, and callWireGuardAdapter.start.- The host app no longer passes a
phase0-stubstart option on every connect; PacketTunnel now uses the installed provider configuration mode, so an installed Phase 1 profile can start through the WireGuardKit path. - The host app now prefers Phase 1
NETunnelProviderManagerconfigurations when multipleVPN Routerconfigurations exist, and reconnect installs the selected profile automatically if the current manager is still a Phase 0 stub. - A signed run reached the WireGuardKit start path, but failed because the installed
provider configuration contained zero selected routes. The visible
networkd_settings_read_from_file_lockedsandbox message was not the primary failure signal; the PacketTunnel log reported an empty route plan. - The host app now rejects install/connect when the selected profile has no
resolvable IPv4 routes, and Connect refreshes the selected profile's
NETunnelProviderManagerpayload immediately before starting so the provider receives the current route plan. - A signed app run then showed all selected domains unresolved in the host app. Host app and PacketTunnel entitlements now include outbound network client permission so sandboxed DNS lookup and WireGuard UDP traffic are not blocked by the app sandbox. Long status/diagnostic messages are now scrollable and selectable in the SwiftUI UI.
- The next signed check reported provider diagnostics with 26 routes, but system
inspection showed the VPN service disconnected and PacketTunnel logs reported
WireGuardKit: Unable to update bind: listen udp4 :0: bind: operation not permitted. PacketTunnel now also has the sandbox network server entitlement for UDP bind, and provider diagnostics now return the latest WireGuardKit runtime message instead of only reporting that WireGuardKit is linked. - A follow-up signed run reported
WireGuardKit: Routine: receive incoming v4 - started. System logs showutun7DNS and IPv4 settings applied, VPN Router status changed to connected, and the selected/32IPv4 routes appeared onutun7. A later stop/uninstall command removedutun7IPv4, DNS, and routes, confirming disconnect cleanup for this run. - A later signed split-route check confirmed selected traffic routing:
route -n get 172.217.209.136resolved toutun7through a/32route, whileroute -n get 1.1.1.1stayed on the normalen6default gateway. - The macOS profile list now supports deletion. Deleting a profile removes profile
metadata and its Keychain private key. Shared VPN Sites are deliberately kept
separate from profiles, so deleting or replacing a VPN profile does not discard
the user's site list. If the deleted profile is currently installed in
NETunnelProviderManager, the app stops that tunnel if needed and removes the stale manager configuration. - VPN Sites are now stored in a shared rule bucket instead of under the selected profile ID. Existing profile-scoped site rules are migrated into the shared list when the Sites view loads and no shared list exists yet.
- Phase 1 split-route now leaves imported WireGuard DNS servers out of the active
tunnel configuration. The MVP uses pre-resolved
/32routes, so installing tunnel DNS globally can break unrelated DNS queries or conflict with tools such as AdGuard while the routed site IPs still use the tunnel. - A signed split-route run after the DNS change kept
utun7alive with selected/32routes. The selected WireGuard endpoint45.63.126.216geolocated to JP and remained routed through the normalen6gateway, while a selected YouTube address (172.217.209.136) routed throughutun7. A forced HTTPS request towww.youtube.comresolved to that routed IP returnedHTTP/2 200, confirming selected traffic can pass through the tunnel. - A Swift Package test harness now compiles the app's actual shared rule and route
planner sources. The initial focused tests cover domain normalization, YouTube and
Netflix expansion, disabled rules, IPv4 validation, subdomain matching, route
deduplication, and route-count enforcement.
swift testpasses all eight tests. - Static route plans now deduplicate by destination IPv4 address rather than by
address/domain pair. If multiple selected CDN domains resolve to the same address,
Network Extension receives one
/32route with a deterministic source-domain label. Plans exceeding 512 unique IPv4 routes fail before tunnel installation. - The unsigned Xcode build succeeds after preparing
libwg-go.ain the selected temporary DerivedData products directory. A fresh DerivedData directory still requires the existing WireGuard Go bridge preparation step before linking. - Static route plans now carry generation and expiration timestamps. The provisional Phase 2 policy refreshes after five minutes and expires a plan after fifteen minutes. Previously installed payloads without timestamps receive a bounded fifteen-minute lifetime when the provider loads them.
- While connected, the host app rebuilds the pre-resolved route plan every five
minutes and sends a schema-versioned
replace-routesprovider message containing only the profile id and route plan. It does not send sanitized configuration text or Keychain material in the live-update message. A manual Refresh Routes action uses the same path. - PacketTunnel validates the active profile, non-empty
/32routes, uniqueness, expiry, and the 512-route limit before callingWireGuardAdapter.update. A failed update retains the previous bounded plan. A successful update replaces the WireGuard AllowedIPs and Network Extension settings and resets the provider-owned expiration timer. If no refresh succeeds before expiry, the provider requests tunnel cancellation instead of allowing an indefinitely stale split-route plan. - A signed real-Mac run verified live route replacement. A manual update at
01:09:08 KST and the next automatic five-minute update at 01:14:19 KST each
entered
reassertingbriefly and returned to Connected without replacingutun7. After the automatic update, 25 selected/32routes remained onutun7, the control address and WireGuard endpoint remained onen6, and a forced YouTube request returned HTTP/2 200. Expiry cancellation remains an intentionally disruptive signed test and has not yet been run. - Route planning now checks selected and media-expanded domains for usable AAAA answers without recording the IPv6 addresses themselves. Matching domains are carried as an IPv6 bypass-risk list in the versioned plan. Home, VPN Sites, connect/refresh status, and provider diagnostics clearly warn that the current IPv4-only MVP cannot route those IPv6 connections.
swift testnow passes thirteen focused tests, including deterministic lifetime, refresh/expiration boundaries, and backward-compatible decoding of older route plans plus IPv6 bypass-domain filtering. The unsigned Xcode host/extension build also succeeds. The new IPv6 warning UI still needs inspection in a signed build.- Settings originally provided 경로 계획 만료 시 자동으로 연결 해제 as a user-controlled value. The parity hardening pass on 2026-07-30 removed that unsafe override: Settings now reports mandatory fifteen-minute expiry protection, PacketTunnel always arms it, and the host removes any legacy disabled preference. If an upgrade finds a connected legacy provider, the host sends a fixed enable-only migration request and disconnects VPN Router if the provider cannot confirm re-arming. No request can disable this protection.
- The SwiftUI navigation, forms, buttons, empty states, confirmation dialogs, status messages, route summaries, diagnostics, and user-facing parser/runtime errors are now written in Korean. Protocol names such as WireGuard and Packet Tunnel, IP terminology, domain names, and internal configuration keys retain their technical names where translation would reduce clarity.
- Two focused settings tests now verify that a legacy false value cannot disable protection and that migration removes it while protection remains enabled. The current complete suite passes 59 focused checks, and the unsigned Debug and optimized Release Host/extension builds succeed.
- A signed real-Mac expiry test paused only the host app after a manual route refresh,
leaving PacketTunnel connected without its five-minute refresh source. With the
fail-safe at its default enabled value, the VPN changed from Connected to
Disconnected at the fifteen-minute plan boundary. The selected
utunroutes were removed, and resuming the host app did not silently reconnect the expired tunnel. - A connected-app lifecycle check confirmed that quitting the SwiftUI host leaves the Packet Tunnel connected, and reopening the signed app starts one host instance and resynchronizes to Connected. Terminating the PacketTunnel process changed the VPN to Disconnected within about five seconds and removed the selected routes; a normal reconnect launched a new extension process and restored the selected routes.
- Streaming dogfooding is only partially successful. Forcing
www.youtube.comto a currently routed YouTube IPv4 address returned HTTP/2 200 and YouTube context countryJP, confirming that selected traffic can use the Japanese exit. However, freshwww.youtube.comandwww.netflix.comanswers, and later rotating root-domain answers, were not always present in the static route plan and therefore used the normalen6route. The current pre-resolution expansion must cover the concretewwwhosts and address DNS answer rotation before the combined YouTube/Netflix Japan-exit acceptance item can pass.
No real WireGuard .conf or private key was read, printed, or stored during this
verification.
- Add Swift domain rule model and media-domain expansion semantics for YouTube and Netflix.
- Add Swift WireGuard config models, parser, sanitizer, import result, and summary.
- Verify parser and expansion behavior with sanitized fake data.
- Store imported private keys in Keychain.
- Store sanitized profile metadata on disk.
- Add a profile import service that separates Keychain secrets from stored metadata.
- Connect file-based WireGuard import UI with sanitized metadata storage.
- Store selected domain rules on disk.
- Resolve selected domains before tunnel route application.
- Build static IPv4
/32included-route plans from selected domains. - Build
NETunnelProviderManagerconfigurations from imported profile metadata. - Pin WireGuardKit and build
wireguard-go-bridge. - Connect parsed configuration to WireGuardKit and the Packet Tunnel provider.
- Keep profile deletion separate from the shared VPN Sites list.
- Avoid installing WireGuard profile DNS globally in Phase 1 split-route mode.
- Add domain normalization tests.
- Add YouTube and Netflix expansion tests.
- Deduplicate resolved routes by destination IPv4 address.
- Reject route plans above a bounded 512-route safety limit.
- Add a five-minute refresh and fifteen-minute route-plan TTL policy.
- Implement schema-versioned live route replacement through
WireGuardAdapter.update. - Verify live route replacement on a signed real-Mac build.
- Verify expiry cancellation on a signed real-Mac build.
- Detect AAAA answers and clearly report IPv6 bypass risk.
- Verify YouTube and Netflix
wwwtraffic through a Japanese exit in a signed build while retaining the normal control route. Arbitrary rotating media/CDN subdomains remain a DNS Proxy follow-up.
- Recheck current Apple deployment and entitlement requirements using primary documentation.
- Confirm that a macOS DNS Proxy must be packaged as a separate system extension, not added to the Packet Tunnel app-extension target.
- Add a read-only signed-build probe that loads DNS Proxy preferences without saving, removing, or enabling a configuration.
- Document the provisioning, activation, forwarding, privacy, coexistence, and
fail-safe checkpoints in
docs/macos-phase3-dns-proxy-spike.md. - Verify the Phase 3 probe with an unsigned host/extension build and rerun all fifteen focused tests.
- Run signed checkpoint A. The host could read DNS Proxy preferences, a VPN Router configuration existed, and it was not enabled.
- Add a separate minimal DNS Proxy system-extension target and embed it at
Contents/Library/SystemExtensionsusing a bundle-matching filename. - Add an explicit diagnostic activation request. It does not save or enable the DNS Proxy configuration, and the checkpoint-B provider rejects unexpected flows.
- Diagnose the first signed checkpoint-B activation failure. NetworkExtension
category validation rejected an
NEMachServiceNamethat was not prefixed by the extension's App Group; the system automatically removed the invalid staged copy. - Align
NEMachServiceNamewith the shared App Group while keeping DNS Proxy configuration disabled. - Confirm development provisioning for the separate DNS Proxy system-extension target.
- Activate the minimal system extension in a signed build without accepting DNS
flows. macOS reported
activated enabled, while the DNS Proxy configuration remained disabled. - Implement a bounded UDP and TCP forwarding candidate using Network.framework, preserving DNS payloads and applying flow metadata to forwarded connections.
- Add defensive DNS response parsing tests for target matching, A records, CNAME TTL propagation, truncation, and compression-pointer loops.
- Publish only the normalized, expanded saved target-domain set to the shared App Group and retain only bounded target address/TTL observations.
- Add an explicit diagnostic enable action, an immediate-disable recovery action, and ownership checks that refuse to modify another provider's DNS Proxy configuration.
- Add aggregate observation diagnostics that report counts and timestamps without displaying retained domains or addresses.
- Run the first explicit signed UDP forwarding attempt. The DNS Proxy configuration activated, but the first UDP query received no response; the configuration was immediately disabled and normal DNS recovered.
- Add privacy-preserving runtime counters for provider start/stop, accepted flow type, flow open, upstream readiness, response delivery, and error domain/code before retrying.
- Verify one signed UDP query reaches its upstream resolver and returns a DNS response while the diagnostic configuration is enabled, then disable the configuration and confirm normal DNS recovery.
- Diagnose why the host app could not read provider runtime observations even
though both signed targets had the same App Group entitlement. The DNS Proxy
provider runs as
root, while the host app runs as the logged-in user, so an App GroupUserDefaultsfile is not a suitable live IPC channel for this provider. - Replace the diagnostic App Group file exchange with the provider's
NEMachServiceNameXPC service. The provider now retains only bounded in-memory target and aggregate observation data; the host sends targets and fetches a schema-versioned snapshot over privileged Mach-service XPC with retry and timeout. - Verify the new XPC diagnostic channel in signed build 4 on a real Mac. The host successfully sent the expanded target-domain set, and a snapshot reported 28 active and 4 expired target IPv4 observations without displaying retained domain or address values.
- Repeat signed UDP forwarding with the XPC diagnostic channel. Runtime
diagnostics reported provider start 1, UDP flows accepted/opened/upstream-ready
64, responses received/delivered 63, and forwarding failures 0. A controlled
youtube.comA query returned four answers through the system resolver. - Make the Diagnostics detail vertically scrollable and reflow its DNS Proxy controls vertically when the available width cannot fit the horizontal row.
- Verify the Diagnostics scrolling change in signed build 5. The owner confirmed that the detail scrolls vertically and the bottom status message remains reachable. The current 720-point minimum detail width prevents shrinking the window far enough to exercise the vertical control fallback.
- Verify TCP forwarding on a signed real-Mac run. One controlled
youtube.comTCP A query returned four answers; runtime diagnostics reported TCP flows accepted 1, total flows opened/upstream-ready/responses received/delivered 15, and forwarding failures 0. - Verify Packet Tunnel and DNS Proxy simultaneous operation on a signed real
Mac. With the Packet Tunnel connected, 33 selected IPv4 routes remained on its
utuninterface and the control address remained on the normalen6interface while one UDP and one TCP DNS query each returned four answers. The Packet Tunnel stayed connected, and DNS Proxy diagnostics reported 15 responses delivered, including one accepted TCP flow, with zero forwarding failures. - Verify ordered teardown after the simultaneous-provider run. Disabling only
the DNS Proxy left the Packet Tunnel connected with all 33 selected routes and
normal DNS available. Disconnecting the Packet Tunnel then stopped its provider,
removed all routes from that
utuninterface, and left normal DNS healthy. - Add an explicit diagnostic route-update candidate that merges active DNS
observations into the installed static route plan, deduplicates by IPv4 address,
rejects invalid observations without displaying their value, enforces the
combined 512-route limit, and bounds the plan by the earliest usable observation
TTL. It reuses the existing schema-versioned Packet Tunnel
replace-routesmessage and is not an automatic background refresh loop. - Confirm the dynamic-plan fail-safe behavior during the first signed update
attempt. The Packet Tunnel accepted 17 new observed routes and reported 48 total,
but the earliest observation TTL was too close for manual inspection and the
expiration timer disconnected the tunnel before the route table was sampled.
The
utunroutes were removed and normal DNS remained healthy. - Repeat the signed update with at least 60 seconds of remaining TTL. Queries
for six known media subdomains produced addresses that were not in the static
pre-resolution plan. The Packet Tunnel accepted 22 new observed routes and
reported 49 total; the system
utunroute count increased from 29 to 52 while the tunnel stayed connected, the control address stayed onen6, and DNS remained healthy. - Add a diagnostic-only 15-second refresh loop that runs only while both the Packet Tunnel and explicitly enabled DNS Proxy are active. It reapplies the bounded active observation set, returns to the static plan after observations expire, and leaves the Packet Tunnel's earliest-TTL expiration fail-safe in control if the host or XPC refresh path stops.
- Run the first signed automatic-refresh attempt. The loop added the observed routes without a button press, increasing the system route count from 29 to 54, but a later identical refresh overlapped the Packet Tunnel's reasserting state and left the VPN reconnecting. Testing stopped and both providers were manually disabled.
- Repeat signed automatic refresh after skipping identical plans and requiring
the live
NETunnelProviderSessionto be connected before every update. Over approximately 85 seconds, the VPN remained connected while the system route count changed from the static 29 to between 40 and 60 as observations appeared and expired. No second reconnecting state occurred. - Add explicit DNS Proxy shutdown cleanup. When the user disables the diagnostic proxy while Packet Tunnel is connected, the host now rebuilds and reapplies a fresh static plan. If that restoration fails, it stops only the VPN Router Packet Tunnel instead of leaving dynamic routes active until their TTL fail-safe.
- Verify signed DNS Proxy shutdown returns the connected Packet Tunnel to a
freshly resolved static plan without disconnecting. In build 10, disabling the
proxy while the tunnel was connected reduced the system
utunroute count from 74 to 31, kept the VPN connected, kept the control address onen6, and left DNS healthy. - Verify baseline coexistence without altering third-party products. Two non-VPN Router system extensions remained activated while DNS Proxy UDP/TCP, Packet Tunnel simultaneous operation, dynamic route updates, and cleanup passed.
- Keep encrypted DNS and Private Relay as manually verified limitations without changing those products automatically.
- Verify actually connected second-VPN coexistence and transition fail-safe with Tailscale/no exit node in signed build 20.
- Exclude captive-portal discovery, sign-in, and recovery from
v0.1.0; require portal authentication before VPN Router is connected. - Record the original Phase 3 decision to keep bounded static pre-resolution as the supported path while DNS Proxy remained a development diagnostic. The 2026-07-28 owner parity direction at the top of this document supersedes that product target after subsequent signed DNS Proxy evidence; distribution provisioning and the clean Release archive remain gates.
- Add a five-second host-to-Packet-Tunnel provider-message timeout so dynamic refresh or static restoration cannot wait indefinitely. Signed build 11 returned normal provider diagnostics through the guarded path with 27 applied routes, fail-safe enabled, and seven IPv6 bypass-risk domains reported.
- Record a scored technical audit with P0-P3 priorities.
- Move profile, Keychain, rule persistence, DNS resolution, and route planning work away from the SwiftUI main execution path.
- Add one-operation-at-a-time state, disabled conflicting controls, visible progress, and VoiceOver progress labels.
- Hide the Phase 0 stub and DNS Proxy spike behind a Debug-only developer option.
- Reduce the detail minimum width from 720 to 440 points.
- Reflow Profiles, Sites, tunnel controls, and action groups between horizontal
and vertical layouts using content-driven
ViewThatFits. - Give each detail page one vertical scroll owner and remove nested message and route-list scroll views.
- Add target-specific delete labels, focusable profile selection, form hints, keyboard shortcuts, and VPN status VoiceOver announcements.
- Add a schema-versioned troubleshooting export containing only bounded status, counts, and timestamps. It has no fields for raw configuration, keys, domains, addresses, DNS payloads, or free-form provider logs.
- Add disconnected-only removal of VPN Router-owned system preferences with bundle-identifier ownership checks.
- Add read-only network path and sleep/wake diagnostics. Wake reloads
NEVPNStatus; no third-party or manual route state is changed. - Add an accessible coral accent and a complete original 16-1024 pixel macOS app icon set.
- Add an arm64/macOS 15
0.1.0 (1)release-verification script, privacy and recovery README, distribution decision, notarization gate, and signed test matrix. - Verify the unsigned Release app, embedded Packet Tunnel, explicit version, minimum OS override, and WireGuard Go archive checksum.
- Inspect compact/wide behavior in the final signed build. The owner confirmed horizontal/vertical reflow, no clipping or overlap, full-page scrolling, visible bottom status messages, and no trapped nested list scrolling on 2026-07-24.
- Inspect offline keyboard and VoiceOver behavior in the final signed build. The owner confirmed the import shortcut, forward/reverse focus order, named profile and site controls, selection state, and meaningful primary-button descriptions on 2026-07-24. The owner chose to skip the connected-status VoiceOver announcement check, so that item remains explicitly unverified.
- Inspect dark/high-contrast behavior in the final signed build. The owner confirmed legible text, controls, selection, accent, warnings, disabled states, and compact content with increased contrast on 2026-07-24.
- Add a persisted per-app screen-theme choice in Settings. Automatic follows the current macOS appearance; Light and Dark override only VPN Router.
- Verify the per-app theme selector in the signed owner build. All three choices applied immediately, Dark persisted across relaunch, and Automatic followed the current macOS appearance on 2026-07-24.
- Run signed sleep/wake and network-change checks while connected.
Sleep/wake passed on 2026-07-25: both counters incremented, the Packet Tunnel
remained healthy with a future route-plan expiry, primary and
utunroutes remained correctly separated, and selected/control sites worked. USB LAN path transition also passed: the read-only counter advanced, 31 static routes were refreshed, the tunnel stayed healthy, the default route returned to USB LAN, and selected/control sites continued working. - Save and inspect a redacted troubleshooting file from the signed UI.
The first attempt hit
EXC_BREAKPOINTwhile constructing a directNSSavePanel. That modal AppKit path has been replaced with SwiftUIfileExporter. The next attempt exposed a missing user-selected file write entitlement; the Host App now has read/write access only for files selected through the system panel. The final signed retry saved valid schema-versioned JSON; owner inspection confirmed bounded status/count fields and no configuration, key, domain, IP, DNS payload, or free-form log on 2026-07-24. - Restore the arm64 Debug
libwg-go.aafter the signed-test Clean Build Folder action removed the manually prepared WireGuard Go bridge product. This is the known upstream Swift package integration limitation; another clean requires rebuilding the bridge before Packet Tunnel can link. - Re-test owned configuration removal while disconnected. The signed recovery
action removed the only VPN Router system preference and left zero connected
services and no routes on the previous
utun, while preserving one imported profile and three selected sites. After reinstalling from the preserved profile, the owner also confirmed removal was disabled while connected and enabled after disconnect. - Re-run connect, selected/control routing, disconnect, relaunch, and extension
termination checks for the final signed build.
Signed connect and split-routing passed on 2026-07-24: 31 app routes were
applied, the system default route remained on the primary interface, VPN routes
were present on one
utun, and both selected and control sites loaded. The Host quit/relaunch also passed: the tunnel and routes survived without the Host App, and relaunch resynchronized to connected without another connect request. Sleep/wake, network transition, and final-build extension termination also passed. Force-terminating only Packet Tunnel changed the app to disconnected, removed all routes from its previousutun, preserved the primary default route, and left ordinary internet access working. Disconnected owned configuration removal and the final normal disconnect also passed. After normal disconnect, the preference remained installed while the connection, provider process, and VPNutunroutes were gone. - Decide/fix findings from the signed matrix, re-run the final audit at 18/20,
pass 35 core tests and Xcode Analyze, and verify the unsigned arm64/macOS 15
0.1.0 (1)Release package.
- Add Keychain storage for imported WireGuard private keys.
- Add sanitized profile metadata storage.
- Connect file-based WireGuard import UI with sanitized metadata storage.
- Add profile deletion that removes profile metadata and Keychain secrets without deleting the shared VPN Sites list.
- Pin WireGuardKit.
- Add the macOS
wireguard-go-bridgebuild target required by WireGuardKit. - Link WireGuardKit where required.
- Build
NETunnelProviderManagerconfigurations from imported profile metadata. - Load parsed tunnel configuration in the Packet Tunnel provider without logging secrets.
- Connect WireGuardKit adapter start/stop to the Packet Tunnel lifecycle.
- Resolve selected domains before tunnel route application.
- Build IPv4
/32included-route plans. - Apply
NEPacketTunnelNetworkSettings.ipv4Settings.includedRoutes. - Keep the VPN endpoint outside recursive tunnel routing.
- Verify selected test traffic uses the VPN exit and control traffic keeps the normal route.
- Verify disconnect removes Network Extension-owned route and DNS state.
- Verify app relaunch resynchronizes from
NEVPNStatus.
- Add domain normalization tests.
- Add YouTube and Netflix expansion tests.
- Deduplicate DNS resolution results.
- Add route count limits.
- Add route TTL and refresh policy.
- Reapply tunnel settings safely as route plans change.
- Detect or clearly report IPv6 bypass risk.
- Record YouTube and Netflix Japan-exit dogfooding checks.
- Confirm DNS Proxy entitlement availability.
- Spike Packet Tunnel and DNS Proxy simultaneous operation.
- Verify UDP and TCP DNS flow visibility.
- Test coexistence with AdGuard, security DNS, and another VPN without altering them.
- Verify dynamic route updates can be sent to the Packet Tunnel safely.
- If the DNS Proxy approach fails, explicitly scope the MVP to pre-resolved routes.
- Implement real navigation for Home, VPN Profiles, VPN Sites, Diagnostics, and Settings.
- Build profile import, rename, select, and delete workflows.
- Build site rule management.
- Build diagnostics and recovery views.
- Build settings for safety behavior and limitations.
- Write user-facing conflict and error messages.
- Add troubleshooting export with secret redaction.
- Add signed real-Mac integration checklists.
- Document minimum macOS, architecture support, privacy, limitations, and recovery.
- Add packaging and notarization only after the owner chooses distribution.
- The owner selected an approved Team in Xcode without committing the Team ID.
- The owner replaced the generated
com.simple...identifiers with approved reverse-DNS identifiers in Xcode. - The Host App target has the Network Extensions capability connected, and the Packet Tunnel extension remains embedded in the host app.
Perform these in Xcode rather than editing project.pbxproj by hand while the
project is open:
- Select the
VPNRouterproject, then theVPNRouterHost App target. - In Signing & Capabilities, select the owner-approved Team.
- Replace the Host App bundle identifier with the owner-approved reverse-DNS value,
for example
com.owner.vpnrouter. - Add the Network Extensions capability and enable Packet Tunnel.
- Select the
PacketTunneltarget, use the same Team, and set its bundle identifier to the host identifier plus.PacketTunnel, for examplecom.owner.vpnrouter.PacketTunnel. - Confirm both App IDs can provision the Network Extensions entitlement, then build and launch from Xcode on the development Mac.
The phase is complete only after a signed development build provides real-Mac evidence that:
- the host app launches with the Packet Tunnel extension embedded;
NETunnelProviderManagerinstalls and starts the configuration;- the stub provider records entry into
startTunnel; - disconnect records entry into
stopTunnel; and - the status shown by the host app is derived from
NEVPNStatus.
Compilation alone does not satisfy this gate.
The macOS continuation was run on macOS 26.5.2, Apple M1 Max arm64, Xcode 26.6
(17F113), and Swift 6.3.3 after fast-forwarding main to the Windows parity
handoff.
Completed implementation:
- the normal consumer coordinator now includes
activatingDNSProxyExtensionafter preflight and before Packet Tunnel preparation; DNSProxySystemExtensionControllerprovides an awaited activation operation, keeps the connection pending during macOS approval, and returns bounded restart/failure results to the coordinator;- no Packet Tunnel or DNS Proxy preference mutation occurs before extension activation completes;
- the existing reverse-order owned cleanup remains covered at every later failure stage;
- profile rename now preserves the profile UUID and Keychain secret reference, updates only sanitized metadata atomically, and is disabled while connected;
- the five native screens now use the Windows product information hierarchy: Home, VPN Profiles, VPN Sites, Troubleshooting, and Settings;
- Home contains one prominent Connect/Disconnect action, consumer readiness, selected profile/site/route summaries, a non-network-mutating connection-plan readiness action matching Windows, and recent plain-language status;
- provider status, route refresh, diagnostic export, and owned recovery controls are contained in Troubleshooting rather than duplicated on Home;
- native SwiftUI adaptive layout, one detail scroll owner, keyboard shortcuts, VoiceOver labels, increased-contrast colors, and Automatic/Light/Dark behavior remain in place.
Automated evidence:
swift testpassed 25 XCTest cases plus 30 Swift Testing cases (55 focused checks), including the new system-extension activation stage, eight injected connection failure cases, and four profile-rename policy cases;- the unsigned arm64 Debug Host App, Packet Tunnel, and DNS Proxy System Extension build succeeded;
- Xcode Analyze succeeded for the complete scheme;
- a clean whole-module
-Ounsigned arm64/minimum-macOS-15 Release Host App and both extensions compiled and embedded successfully without disabling optimizer passes after the generic continuation gates were replaced with non-generic one-shot state; git diff --checkpassed.
The first unsigned build attempt exposed an Xcode 26 explicit-module cache race
while compiling WireGuardKit C sources (_DarwinFoundation2.pcm was referenced
before it existed). Moving only the temporary module cache aside and building with
SWIFT_ENABLE_EXPLICIT_MODULES=NO,
CLANG_ENABLE_EXPLICIT_MODULES=NO, and indexing disabled succeeded. The reused
temporary libwg-go.a still emits the previously recorded malformed
LC_DYSYMTAB warning.
That fallback limitation was subsequently cleared without changing the system
toolchain. The official Go 1.26.5 arm64 archive was checksum-verified and unpacked
under /private/tmp, then supplied through VPNROUTER_GO_BINARY. The canonical
release script rebuilt libwg-go.a from a clean release DerivedData directory and
completed the unsigned 0.1.0 (1) arm64 Release package for minimum macOS 15.
No earlier LC_DYSYMTAB or macOS-26 deployment warning appeared. vtool
confirmed minos 15.0 for the Host App, Packet Tunnel, DNS Proxy System Extension,
and representative Go/CGO archive objects.
The first two otherwise-identical clean bridge builds differed in Go's embedded
build ID and archive table timestamp. A later cross-commit check also found Go
VCS revision stamping. The vendored bridge Makefile now clears the unused build
ID, disables VCS stamping, and runs ranlib -D. Two independent clean bridge
builds then produced the same SHA-256:
29177134ad37d6105857d926977f0669759e1e4b542803d27dd5b794f10fd3fd.
The release script records the Go version and fails if any of those nested binaries or inspected archive members does not match the requested minimum macOS version. This is still unsigned compile/package evidence, not a distributable or runtime-proven artifact.
Signing evidence preparation was then hardened without accessing any private signing metadata:
scripts/macos/verify-signed-app.shnow requires the Host App, Packet Tunnel, and DNS Proxy System Extension with exact bundle/version/minimum-OS/arm64 metadata;- it verifies each nested signature and the complete signature chain, then checks the exact Network Extension, System Extension install, app-group, Keychain, network, and diagnostic-export entitlement contract;
- development mode accepts only owner development or Developer ID signatures;
distribution mode additionally requires hardened Developer ID Application
signatures, while
--notarizedrequires stapler and Gatekeeper success; - an explicit
adhoc-testmode passed a complete isolated fixture, rejected a fixture missing the DNS Proxy provider entitlement, and was rejected by distribution mode; - the canonical unsigned Release verifier now creates and checks the isolated ad-hoc fixture automatically.
The DNS Proxy System Extension is no longer described as optional for public packaging. It is required for dynamic discovery, target AAAA protection, and DNS ownership; removing it would violate the parity contract.
This is not signed Network Extension runtime evidence. The next gate is an
owner-signed run of the normal Home connection path, including fresh System
Extension approval/pending UI, full DNS readiness before Connected, reverse
cleanup on denial/failure, profile rename persistence, and compact/wide
accessibility inspection. No Team ID, signing identity, provisioning data,
configuration text, private key, domain list, DNS payload, or resolver address was
recorded.
The existing owner signing configuration then produced an optimized Release app
from commit f390972 without exposing signing metadata. The signed-app verifier
accepted the Host App, Packet Tunnel, and DNS Proxy System Extension signatures,
metadata, minimum OS, architecture, and required entitlements. A no-connect
launch smoke also passed. This advances the gate to owner-operated System
Extension approval and live connection testing; it does not prove activation,
routing, DNS, disconnect, or recovery.
The signed Home Connect action then reached the required manual browser secure-DNS/Private Relay confirmation. Exercising Cancel left Packet Tunnel unconfigured and preserved the already-approved DNS Proxy System Extension state. The confirmation was reopened without auto-accepting it and remains subject to owner input. A subsequent check found the confirmation dismissed and Packet Tunnel still unconfigured, so no approval or successful connection is inferred. This proves the current signed preflight cancellation path does not partially install the tunnel; it is not a successful connection result.
The owner-signed consumer run exposed and resolved several runtime-only defects that compilation could not reveal:
- launching the signed app from a temporary build directory caused DNS Proxy
System Extension activation to reject the containing-app location; installing
the verified app under
/Applicationsadvanced the sequence; - PlugInKit contained a second elected Packet Tunnel registration at the old
temporary path. The stale VPN Router registration alone was removed, leaving
exactly one elected Packet Tunnel at
/Applications/VPNRouter.app; startVPNTunnel()initially reports.disconnectedbefore macOS posts its asynchronous.connectingtransition. The app treated that first sample as a terminal failure and sent its own stop about one millisecond after start, beforeneagentcompleted extension discovery. A bounded five-second initial grace now waits for the first transition while still failing immediately if the tunnel disconnects after progress;- a focused
PacketTunnelStartWaitPolicysuite covers the initial asynchronous transition, disconnect-after-progress, grace expiry, and disconnecting state. The complete suite now passes 25 XCTest cases plus 34 Swift Testing cases (59 focused checks); - the owner-signed optimized Release connected only after Packet Tunnel, owned
DNS Proxy, XPC target publication/readiness, and safety monitoring completed.
A clean normal disconnect returned the native VPN state to
Disconnectedand removed the provider process; - on that signed connection, UDP and TCP each returned seven combined target A answers, zero target AAAA answers, and two unrelated control AAAA answers. YouTube, Netflix, and the control HTTPS endpoint each returned HTTP 200;
- a selected target route used a non-default
utun, while the control destination continued to use the primary default interface; - terminating only the VPN Router Packet Tunnel changed the native state to
Disconnected, disabled the owned DNS Proxy configuration, changed the UI toConnection failed, removed the staleConnectedpresentation, and left the reconnect action available. The following owner-confirmed reconnect succeeded; - the status observer now records
packet-tunnel-runtime-disconnectedwhen a ready Packet Tunnel exits unexpectedly. Status text, color, symbol, and primary action require an active native tunnel state instead of trusting stale coordinator readiness; - a later optimized Packet Tunnel start crash was reduced to a symbol-only crash stack. It occurred while a WireGuard Go callback thread interpolated and mutated the shared Swift runtime-message string. The callback now records only a bounded generic event through a lock; raw WireGuard callback/error text is no longer copied into OS logs or UI diagnostics. The next signed start remained connected and produced no new crash report;
- provider-message IPC makes a connected Packet Tunnel depend on the host
coordinator process. Connected Quit now closes only the main window while
preserving the host, Packet Tunnel, DNS Proxy, and native VPN session.
Reopening restores the existing window in
Connectedstate. After normal disconnect, Quit terminates the host normally. Forced provider loss still uses the verified fail-safe path; - the detail scroll view now exposes visible scroll indicators and content-sized
bounce behavior. Status messages wrap to full height and have a
Copy status messageaction on Home and Troubleshooting. Clipboard equality was verified, and a 700-by-500 compact Troubleshooting window successfully moved content with both page-up and page-down accessibility scroll actions.
The latest installed dogfood app at this checkpoint is owner-signed development
0.1.0 (14), built from commit 8e7c4ae, under /Applications.
The signed verifier passed
the Host App, Packet Tunnel, and DNS Proxy nested structure and entitlements.
The canonical unsigned whole-module -O build still passes for arm64/minimum
macOS 15 with the reproducible WireGuard Go archive SHA-256
29177134ad37d6105857d926977f0669759e1e4b542803d27dd5b794f10fd3fd.
This closes the signed atomic start, normal disconnect, provider-loss recovery, basic DNS/route behavior, long-duration route maintenance, connected UI close/reopen, and reported scroll/copy defects. It does not yet close the current-build 512-route live rejection, DNS ownership-loss, sleep/network-change, second-VPN transition, connected VoiceOver announcement, or Developer ID/notarization/clean-install gates.
Subsequent signed builds repeated and extended the consumer checks:
- UDP and TCP returned target A answers, empty target AAAA, and preserved unrelated control AAAA; selected and control routes remained split and all three HTTPS checks returned 200;
- build 13 connected Quit closed only the main window. The host coordinator,
Packet Tunnel, DNS Proxy, native connected state, and selected
utunroute remained active, andopenrestored the same window as the main window; - all five screens selected successfully at compact and wide sizes. The detail ScrollView exposed page-up/page-down accessibility actions, with long compact pages moving in both directions and fitting wide pages correctly remaining at their boundary;
- Automatic, Light, and Dark changed immediately and the original Automatic selection was restored;
- the selected profile completed a signed UI rename-to-temporary-and-back round trip without reading or printing its Keychain secret;
- status-message copy changed the clipboard to the current non-empty safe message;
- signed SwiftUI export produced a 907-byte schema-2 diagnostic artifact. A bounded scan found no private-key, configuration, endpoint, resolver, or DNS payload pattern. Both temporary exported files were removed after validation.
- a signed connection remained healthy through 16 minutes 30 seconds, crossing
the fifteen-minute safety boundary while the host continued refresh. The
bounded route count changed from 41 to 85 and then 74 as DNS observations were
added and older entries expired; the final diagnostic state was
connectedwith no failure code. UDP/TCP target A, empty target AAAA, unrelated AAAA, and the selectedutunroute remained healthy before normal disconnect; - an intermediate lifecycle monitor incorrectly searched the provider command
line for its bundle identifier and produced false
PacketTunnel absentresults. The corrected check uses the exact executable process name and was cross-checked against native state and the selected route. No provider-loss claim is based on the discarded command-line pattern.
Exact-commit build 14 repeated the final smoke: all five sidebar VoiceOver
labels were present, the owner-confirmed connection reached Connected, target
A remained available while target AAAA was empty and unrelated AAAA remained
available, a selected route used utun, connected Quit preserved all three
processes and native state for the checked interval, open restored the main
window, and normal Disconnect removed the Packet Tunnel process.
macOS 화면은 Windows와 같은 다섯 영역과 작업 순서를 유지하면서도 다른 제품이나 Windows 화면을 그대로 복사한 것처럼 보이지 않도록 다듬었습니다.
- 큰 원형 연결 버튼을 글자와 아이콘이 함께 있는 넓은 직사각형 버튼으로 바꿨습니다.
- 홈에
이 Mac → 선택 사이트 → VPN흐름을 보여주는 연결 경로 띠를 추가했습니다. - 사이드바, 화면 제목, 작업 진행 표시와 카드의 여백·모서리·코랄 표식을 하나의 macOS 시각 규칙으로 정리했습니다.
- 홈·VPN 프로필·VPN 사이트의 두 카드 영역은 넓은 창에서 같은 폭과 높이, 같은 상단 기준선으로 정렬하고 좁은 창에서는 전체 폭 한 열로 전환하도록 공통 배치를 적용했습니다.
- 공통 작업 순서와 플랫폼별 표현 경계를
docs/ui-design-principles.md에 기록했습니다. Windows 소스는 수정하지 않았습니다. - macOS 26.5.2 arm64, Xcode 26.6, Swift 6.3.3 환경에서
swift test의 25개 XCTest와 35개 Swift Testing 검사, 모두 60개가 통과했습니다. - 기존에 검증해 둔 Debug
libwg-go.a를 새 임시 DerivedData에 준비한 뒤 Host App, Packet Tunnel, DNS Proxy System Extension의 unsigned arm64 Debug 빌드가 통과했습니다. 첫 시도는 새 임시 폴더에 해당 라이브러리가 없어 링크 전에 멈췄으며, UI 컴파일 오류는 아니었습니다.
System Extension을 포함한 앱이 Xcode DerivedData에서 실행될 때 운영체제가
unsupportedParentBundleLocation으로 활성화를 거부하는 조건도 연결 전
검사에 추가했습니다. 이제 연결은 preflighting 단계에서
app-not-installed-in-applications 코드와 함께 응용 프로그램 폴더에
설치한 앱을 다시 열라는 안내를 표시합니다. 개발자용 직접 활성화 경로에도
같은 검사를 두어 운영체제의 원문 파일 경로가 화면에 노출되지 않게 했습니다.
이 결과는 SwiftUI 컴파일과 자동 검사 근거입니다. 서명 앱의 실제 화면, VoiceOver, 작은 창·넓은 창, 연결·DNS·경로 동작을 새로 검증한 결과는 아닙니다.