mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-10-30 22:23:43 +00:00
refactor(login): independent opt-in login item
Drop the UserDefaults intent mirror and read the toggle state only from the system login-item registration status (approval-pending counts as enabled); default to off until the user enables it, and stop resetting it with proxy preferences (ADR-0005).
This commit is contained in:
@@ -13,6 +13,7 @@
|
||||
- **Managed plugin**: A SIP003 plugin executable that the product packages, hash-verifies, re-signs, and distributes only inside the app bundle. The managed set changes only with app releases; users cannot supply their own plugin executables.
|
||||
- **Proxy mode**: The user-facing way traffic is routed through the local proxy, such as PAC, global, manual, or an external PAC configuration.
|
||||
- **Proxy mode selector**: The common user action for choosing one mutually exclusive proxy mode; it is distinct from enabling or disabling the proxy runtime and from configuring an external PAC URL.
|
||||
- **Login item**: The GUI app's registration in the user's login items. It is an independent preference domain: off until the user enables it, its state is only ever the system login-item registration status, and resetting preferences never changes it.
|
||||
- **HTTP proxy mode**: A local proxy mode that accepts HTTP/HTTPS proxy requests directly through the Shadowsocks runtime; it does not require a separate adapter process.
|
||||
- **Legacy Privoxy adapter**: The frozen implementation's HTTP(S)-to-local-SOCKS5 bridge. It is a Legacy-only dependency and is not migrated into 2.0.
|
||||
- **Listen scope**: A user-facing two-state choice of whether the locally provided proxy endpoints — the PAC HTTP endpoint and the external tunnel service's inbound listeners — bind to loopback only or to the host's network-facing address. The host-facing scope intentionally exposes an unauthenticated local proxy to other devices on the network, so users can share the PAC URL or proxy address to machines other than this one.
|
||||
|
||||
@@ -43,50 +43,31 @@ struct SMAppLaunchAtLoginService: LaunchAtLoginControlling {
|
||||
}
|
||||
|
||||
/// Owns the GUI login-item intent independently from the proxy LaunchAgent.
|
||||
/// The setting is enabled by default; registration is attempted at launch and
|
||||
/// again whenever the user changes the toggle.
|
||||
/// The system's login-item registration is the only source of truth: the
|
||||
/// toggle is off until the user enables it, and enabling or disabling only
|
||||
/// issues the corresponding system call before re-reading the status.
|
||||
@MainActor
|
||||
final class LaunchAtLoginController: ObservableObject {
|
||||
static let preferenceKey = "launchAtLogin.enabled"
|
||||
|
||||
@Published private(set) var isEnabled: Bool
|
||||
@Published private(set) var status: LoginItemStatus
|
||||
@Published private(set) var errorMessage: String?
|
||||
|
||||
private let service: LaunchAtLoginControlling
|
||||
private let defaults: UserDefaults
|
||||
|
||||
init(
|
||||
service: LaunchAtLoginControlling = SMAppLaunchAtLoginService(),
|
||||
defaults: UserDefaults = .standard
|
||||
) {
|
||||
init(service: LaunchAtLoginControlling = SMAppLaunchAtLoginService()) {
|
||||
self.service = service
|
||||
self.defaults = defaults
|
||||
isEnabled = defaults.object(forKey: Self.preferenceKey) as? Bool ?? true
|
||||
status = service.status
|
||||
}
|
||||
|
||||
/// 已注册或已请求注册待批准都算开启——两者都只来自系统 API 的状态。
|
||||
var isEnabled: Bool {
|
||||
status == .registered || status == .requiresApproval
|
||||
}
|
||||
|
||||
var requiresApproval: Bool { status == .requiresApproval }
|
||||
|
||||
func syncAtLaunch() {
|
||||
applyDesiredState()
|
||||
}
|
||||
|
||||
func setEnabled(_ enabled: Bool) {
|
||||
isEnabled = enabled
|
||||
defaults.set(enabled, forKey: Self.preferenceKey)
|
||||
applyDesiredState()
|
||||
}
|
||||
|
||||
func resetToDefaults() {
|
||||
defaults.removeObject(forKey: Self.preferenceKey)
|
||||
isEnabled = true
|
||||
applyDesiredState()
|
||||
}
|
||||
|
||||
private func applyDesiredState() {
|
||||
do {
|
||||
if isEnabled {
|
||||
if enabled {
|
||||
try service.register()
|
||||
} else {
|
||||
try service.unregister()
|
||||
|
||||
@@ -29,9 +29,6 @@ struct ShadowsocksXNG2App: App {
|
||||
postLegacyImport: { _ in
|
||||
await controller.legacyImportDidCommit()
|
||||
}))
|
||||
Task { @MainActor in
|
||||
loginController.syncAtLaunch()
|
||||
}
|
||||
// GUI 事件接入内存环形缓冲(spec #21 D5,issue #34):主窗口日志查看器与
|
||||
// 诊断导出的来源;wrapper 侧不注册,仍走 stderr → agent.log 收敛。
|
||||
RuntimeLog.setSink(RuntimeEventStore.shared)
|
||||
|
||||
@@ -353,7 +353,6 @@ extension SettingsView {
|
||||
Task { @MainActor in
|
||||
do {
|
||||
try await proxyController.resetPreferences()
|
||||
loginController.resetToDefaults()
|
||||
draft = proxyController.settings
|
||||
refreshPortOccupancy()
|
||||
} catch {
|
||||
|
||||
@@ -4,62 +4,77 @@ import XCTest
|
||||
|
||||
@MainActor
|
||||
final class LaunchAtLoginServiceTests: XCTestCase {
|
||||
private var defaults: UserDefaults!
|
||||
private var defaultsSuiteName: String!
|
||||
|
||||
override func setUpWithError() throws {
|
||||
defaultsSuiteName = "ssxng-login-tests-\(UUID().uuidString)"
|
||||
defaults = try XCTUnwrap(UserDefaults(suiteName: defaultsSuiteName))
|
||||
}
|
||||
|
||||
override func tearDownWithError() throws {
|
||||
defaults.removePersistentDomain(forName: defaultsSuiteName)
|
||||
defaults = nil
|
||||
defaultsSuiteName = nil
|
||||
}
|
||||
|
||||
func testDefaultIntentRegistersAtLaunch() {
|
||||
func testStartsOffUntilTheUserEnablesIt() {
|
||||
let service = FakeLoginItemService()
|
||||
let controller = LaunchAtLoginController(service: service, defaults: defaults)
|
||||
let controller = LaunchAtLoginController(service: service)
|
||||
|
||||
controller.syncAtLaunch()
|
||||
XCTAssertFalse(controller.isEnabled)
|
||||
XCTAssertEqual(controller.status, .notRegistered)
|
||||
XCTAssertEqual(service.registerCount, 0, "启动不自动注册,默认关闭")
|
||||
}
|
||||
|
||||
func testEnablingRegistersAndReflectsSystemState() {
|
||||
let service = FakeLoginItemService()
|
||||
let controller = LaunchAtLoginController(service: service)
|
||||
|
||||
controller.setEnabled(true)
|
||||
|
||||
XCTAssertTrue(controller.isEnabled)
|
||||
XCTAssertEqual(service.registerCount, 1)
|
||||
XCTAssertEqual(controller.status, .registered)
|
||||
XCTAssertNil(controller.errorMessage)
|
||||
}
|
||||
|
||||
func testToggleUnregistersAndPersistsAcrossControllerInstances() {
|
||||
func testDisablingUnregistersAndReflectsSystemState() {
|
||||
let service = FakeLoginItemService()
|
||||
let controller = LaunchAtLoginController(service: service, defaults: defaults)
|
||||
let controller = LaunchAtLoginController(service: service)
|
||||
controller.setEnabled(true)
|
||||
|
||||
controller.setEnabled(false)
|
||||
|
||||
XCTAssertFalse(controller.isEnabled)
|
||||
XCTAssertEqual(service.unregisterCount, 1)
|
||||
let restored = LaunchAtLoginController(service: service, defaults: defaults)
|
||||
XCTAssertFalse(restored.isEnabled)
|
||||
XCTAssertEqual(controller.status, .notRegistered)
|
||||
}
|
||||
|
||||
func testResetReturnsToEnabledDefault() {
|
||||
func testApprovalPendingCountsAsEnabled() {
|
||||
let service = FakeLoginItemService()
|
||||
let controller = LaunchAtLoginController(service: service, defaults: defaults)
|
||||
controller.setEnabled(false)
|
||||
|
||||
controller.resetToDefaults()
|
||||
service.status = .requiresApproval
|
||||
let controller = LaunchAtLoginController(service: service)
|
||||
|
||||
XCTAssertTrue(controller.isEnabled)
|
||||
XCTAssertTrue(controller.requiresApproval)
|
||||
XCTAssertEqual(service.registerCount, 0)
|
||||
}
|
||||
|
||||
func testRegistrationFailureNamesErrorAndKeepsSystemState() {
|
||||
let service = FakeLoginItemService()
|
||||
service.registerError = FakeError.system
|
||||
let controller = LaunchAtLoginController(service: service)
|
||||
|
||||
controller.setEnabled(true)
|
||||
|
||||
XCTAssertFalse(controller.isEnabled)
|
||||
XCTAssertEqual(controller.status, .notRegistered)
|
||||
XCTAssertEqual(service.registerCount, 1)
|
||||
XCTAssertNil(defaults.object(forKey: LaunchAtLoginController.preferenceKey))
|
||||
XCTAssertTrue(controller.errorMessage?.contains("fake-system-error") == true)
|
||||
}
|
||||
|
||||
private enum FakeError: Error, CustomStringConvertible {
|
||||
case system
|
||||
|
||||
var description: String { "fake-system-error" }
|
||||
}
|
||||
|
||||
private final class FakeLoginItemService: LaunchAtLoginControlling {
|
||||
var status: LoginItemStatus = .notRegistered
|
||||
var registerCount = 0
|
||||
var unregisterCount = 0
|
||||
var registerError: Error?
|
||||
|
||||
func register() throws {
|
||||
registerCount += 1
|
||||
if let registerError { throw registerError }
|
||||
status = .registered
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Independent opt-in login item
|
||||
|
||||
The GUI's login-item registration is its own preference domain, separate from proxy preferences. It is off until the user explicitly enables it: no registration happens at launch. The system's login-item registration status (`SMAppService.mainApp.status`) is the only source of truth — there is no UserDefaults mirror — and a pending-approval registration counts as enabled because both states come from that system query. Resetting preferences never touches the login item.
|
||||
|
||||
## Status
|
||||
|
||||
Accepted.
|
||||
|
||||
## Considered options
|
||||
|
||||
- Keep the persisted-intent mirror (`launchAtLogin.enabled`, default on) and reconcile at launch: rejected because the mirror can disagree with the system after the user edits the login item in System Settings, producing a toggle that lies about the actual state.
|
||||
- Drop the mirror but keep default-on via a one-time launch registration: rejected because opting the user's machine into a background login item without an explicit action is not 2.0's default posture; the status-menu app is deliberately explicit.
|
||||
|
||||
## Consequences
|
||||
|
||||
- `LaunchAtLoginController` reads `isEnabled` from the system status only; enabling and disabling issue the corresponding system call and re-read the status.
|
||||
- The toggle reflects external changes only after the controller re-reads the status (on init, on toggle, and when approval completes); no continuous observation is added.
|
||||
- Resetting preferences in the settings window no longer calls into the login-item domain; its confirmation message already names only ports, listen scope, and PAC settings.
|
||||
Reference in New Issue
Block a user