Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
a096daf
Add per-server toggle to allow self-signed TLS certificates
tashda Apr 30, 2026
0dd84ab
Discover Z2M on HA add-on port 8099 in addition to 8080
tashda Apr 30, 2026
14240a2
Add Identify action for devices that expose the Identify cluster
tashda Apr 30, 2026
65222e1
Add three-step pairing wizard from the Devices tab
tashda Apr 30, 2026
f470312
Add first-launch onboarding wizard
tashda Apr 30, 2026
9b5ad6d
Onboarding feedback: app icon, drop concepts page, real Connect screen
tashda Apr 30, 2026
5a79931
Welcome wizard polish round 2
tashda Apr 30, 2026
50ce0d8
Pairing wizard rewrite + interview state staleness fix
tashda Apr 30, 2026
2011945
Silence unused-result warning on MainActor.run in identifyDevice
tashda Apr 30, 2026
7d05981
Pairing wizard polish round 2
tashda Apr 30, 2026
5c4d581
Pairing wizard: use an alert (not action sheet) for the cancel-with-n…
tashda Apr 30, 2026
3a4217a
Pairing wizard polish round 3
tashda Apr 30, 2026
b223a88
Show "via X" scope on the pairing wizard's network-open row
tashda Apr 30, 2026
a77530d
Preserve permit_join target across bridge/info refreshes
tashda Apr 30, 2026
a91e0cc
Stop wiping deviceFirstSeen on AppStore.reset()
tashda Apr 30, 2026
40487f2
Make Recently Added window configurable; preserve permit_join end acr…
tashda Apr 30, 2026
69c01e9
Make permit_join state global; tidy Recently Added picker; drop Welco…
tashda Apr 30, 2026
12f6273
Drop seconds-remaining from the home bridge-card permit-join row
tashda Apr 30, 2026
3c64127
Mark devices online when interview completes successfully
tashda Apr 30, 2026
9e0de59
Settings cleanup: fold Bulk OTA into OTA Updates; drop duplicate Rest…
tashda Apr 30, 2026
d35e4c9
Group hero avatar: let user pick which 1–2 members appear
tashda Apr 30, 2026
3d6543f
Group avatar: live-update on save and prefill defaults when picker opens
tashda Apr 30, 2026
2aec46f
Resolve group avatar through the store everywhere it's rendered
tashda Apr 30, 2026
486bc30
Convert GroupAvatarStore to @Observable singleton so list rows reacti…
tashda Apr 30, 2026
d95a2e9
Empty group: add an Add Members button to the empty-state placeholder
tashda Apr 30, 2026
0c53040
Empty group placeholder gets a normal Form-row Add Members button
tashda Apr 30, 2026
7756c3a
Empty group: proper iOS-style centered Add Members capsule button
tashda Apr 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 12 additions & 5 deletions Shellbee.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,8 @@
Features/Groups/AddGroupMemberDeviceRow.swift,
Features/Groups/AddGroupMembersSheet.swift,
Features/Groups/AddGroupSheet.swift,
Features/Groups/GroupAvatarPickerSheet.swift,
Features/Groups/GroupAvatarStore.swift,
Features/Groups/GroupCard.swift,
Features/Groups/GroupCardFooterBar.swift,
Features/Groups/GroupCardHeader.swift,
Expand Down Expand Up @@ -207,12 +209,17 @@
Features/Notifications/FastTrackBanner.swift,
Features/Notifications/InAppNotificationBanner.swift,
Features/Notifications/InAppNotificationOverlay.swift,
Features/Onboarding/OnboardingConnectPage.swift,
Features/Onboarding/OnboardingModel.swift,
Features/Onboarding/OnboardingTestPage.swift,
Features/Onboarding/OnboardingView.swift,
Features/Pairing/PairingWizardModel.swift,
Features/Pairing/PairingWizardView.swift,
Features/Settings/AboutView.swift,
Features/Settings/AcknowledgementsView.swift,
Features/Settings/AppGeneralView.swift,
Features/Settings/AppLiveActivitiesView.swift,
Features/Settings/AppNotificationSettingsView.swift,
Features/Settings/AppPerformanceView.swift,
Features/Settings/AvailabilitySettingsView.swift,
Features/Settings/Backup/BackupPayload.swift,
Features/Settings/Backup/BackupView.swift,
Expand Down Expand Up @@ -822,7 +829,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.3.1;
MARKETING_VERSION = 1.4.0;
PRODUCT_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_ID)";
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
Expand Down Expand Up @@ -863,7 +870,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.3.1;
MARKETING_VERSION = 1.4.0;
PRODUCT_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_ID)";
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
Expand Down Expand Up @@ -903,7 +910,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.3.1;
MARKETING_VERSION = 1.4.0;
PRODUCT_BUNDLE_IDENTIFIER = "$(APP_WIDGET_BUNDLE_ID)";
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
Expand Down Expand Up @@ -945,7 +952,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.3.1;
MARKETING_VERSION = 1.4.0;
PRODUCT_BUNDLE_IDENTIFIER = "$(APP_WIDGET_BUNDLE_ID)";
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
Expand Down
18 changes: 18 additions & 0 deletions Shellbee/App/AppEnvironment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,24 @@ final class AppEnvironment {
/// Renames a device with an optimistic local update so the UI changes
/// immediately. If the bridge rejects the rename, AppStore reverts the
/// change when `bridge/response/device/rename` arrives with status="error".
/// Asks the device to physically identify itself (blink/beep) via the
/// Zigbee Identify cluster. Z2M exposes this as a writable enum property
/// `identify` with values `["identify"]`. Fire-and-forget — there's no
/// `bridge/response/.../identify` to await, so we surface the in-progress
/// state for ~3s in the UI before clearing it.
func identifyDevice(_ friendlyName: String) {
guard !store.identifyInProgress.contains(friendlyName) else { return }
store.identifyInProgress.insert(friendlyName)
sendDeviceState(friendlyName, payload: .object(["identify": .string("identify")]))

Task { [weak store] in
try? await Task.sleep(for: .seconds(3))
await MainActor.run {
_ = store?.identifyInProgress.remove(friendlyName)
}
}
}

func renameDevice(from: String, to: String, homeassistantRename: Bool) {
let trimmed = to.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, trimmed != from else { return }
Expand Down
2 changes: 1 addition & 1 deletion Shellbee/App/ConnectionSessionController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ final class ConnectionSessionController {
throw Z2MError.invalidURL
}

let events = try await client.connect(url: url)
let events = try await client.connect(url: url, allowInvalidCertificates: config.allowInvalidCertificates)
config.save()
connectionState = .connected
hasBeenConnected = true
Expand Down
15 changes: 15 additions & 0 deletions Shellbee/App/RootView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ struct RootView: View {
@Environment(\.scenePhase) private var scenePhase
@State private var isInitializing = true
@State private var pendingCrash: PendingCrash?
@AppStorage(OnboardingStep.completedKey) private var onboardingCompleted: Bool = false
@State private var showOnboarding = false

var body: some View {
ZStack {
Expand All @@ -26,6 +28,19 @@ struct RootView: View {
onDiscard: { SentryService.shared.discardPending() }
)
}
.fullScreenCover(isPresented: $showOnboarding) {
OnboardingView()
.environment(environment)
}
.onChange(of: isInitializing) { _, stillInitializing in
// First-launch only. Defer until splash dismisses so the cover
// doesn't fight the splash transition.
guard !stillInitializing,
!onboardingCompleted,
environment.connectionConfig == nil
else { return }
showOnboarding = true
}
.task {
// Start the environment (auto-connect if config exists)
await environment.start()
Expand Down
28 changes: 23 additions & 5 deletions Shellbee/Core/Config/AppConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,28 @@ nonisolated enum AppConfig {
/// multiple log lines) reads as one notification, not four.
static let notificationCoalesceWindow: TimeInterval = 1.5

/// How long after a device first joins the network it shows the
/// "Recently Added" badge in the device list. 30 minutes covers
/// most pairing → naming → first-test workflows without lingering
/// on the homepage forever.
static let recentDeviceWindow: TimeInterval = 30 * 60
/// Default window after a device first joins the network during
/// which it appears in the "Recently Added" section of the device
/// list. User-overridable via Settings → General; this default
/// covers most pairing → naming → first-test workflows without
/// lingering forever.
static let recentDeviceWindow: TimeInterval = recentDeviceWindowDefaultMinutes * 60

/// User-facing key + options for the Recently-Added window picker
/// in Settings → General. Stored as minutes. To hide the section
/// entirely the user toggles "Show Recents" off in the device
/// list's Sort menu — that's the single source of truth for
/// visibility, this picker only controls the window length.
static let recentDeviceWindowKey = "DeviceList.recentWindowMinutes"
static let recentDeviceWindowDefaultMinutes: TimeInterval = 30
static let recentDeviceWindowOptionsMinutes: [Int] = [5, 15, 30, 60, 120, 240, 1440]

/// Resolves the active window (in seconds) honoring the user's
/// stored preference if any, falling back to the default.
static var configuredRecentDeviceWindow: TimeInterval {
let raw = UserDefaults.standard.object(forKey: recentDeviceWindowKey) as? Int
let minutes = raw.map(TimeInterval.init) ?? recentDeviceWindowDefaultMinutes
return minutes * 60
}
}
}
27 changes: 26 additions & 1 deletion Shellbee/Core/Models/BridgeInfo.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ struct BridgeInfo: Codable, Sendable, Equatable {
let permitJoin: Bool
let permitJoinTimeout: Int?
let permitJoinEnd: Int?
/// Friendly name of the router (or coordinator) the current permit-join
/// session is scoped to. `nil` when the network is open via all devices.
/// Z2M doesn't include this in `bridge/info` — we capture it from the
/// `bridge/event` `permit_join` payload and from our own outbound
/// requests to keep the wizard honest about scope.
let permitJoinTarget: String?
let restartRequired: Bool
let config: BridgeConfig?

Expand All @@ -29,6 +35,7 @@ struct BridgeInfo: Codable, Sendable, Equatable {
logLevel = try container.decode(String.self, forKey: .logLevel)
permitJoin = try container.decode(Bool.self, forKey: .permitJoin)
permitJoinTimeout = try container.decodeIfPresent(Int.self, forKey: .permitJoinTimeout)
permitJoinTarget = nil
restartRequired = try container.decode(Bool.self, forKey: .restartRequired)
config = try container.decodeIfPresent(BridgeConfig.self, forKey: .config)

Expand All @@ -41,7 +48,7 @@ struct BridgeInfo: Codable, Sendable, Equatable {
}

// Also need an explicit memberwise init for Previews and AppStore updates
init(version: String, commit: String?, coordinator: CoordinatorInfo, network: NetworkInfo?, logLevel: String, permitJoin: Bool, permitJoinTimeout: Int?, permitJoinEnd: Int?, restartRequired: Bool, config: BridgeConfig?) {
init(version: String, commit: String?, coordinator: CoordinatorInfo, network: NetworkInfo?, logLevel: String, permitJoin: Bool, permitJoinTimeout: Int?, permitJoinEnd: Int?, permitJoinTarget: String? = nil, restartRequired: Bool, config: BridgeConfig?) {
self.version = version
self.commit = commit
self.coordinator = coordinator
Expand All @@ -50,6 +57,7 @@ struct BridgeInfo: Codable, Sendable, Equatable {
self.permitJoin = permitJoin
self.permitJoinTimeout = permitJoinTimeout
self.permitJoinEnd = permitJoinEnd
self.permitJoinTarget = permitJoinTarget
self.restartRequired = restartRequired
self.config = config
}
Expand All @@ -64,10 +72,27 @@ struct BridgeInfo: Codable, Sendable, Equatable {
permitJoin: permitJoin,
permitJoinTimeout: permitJoinTimeout,
permitJoinEnd: permitJoinEnd,
permitJoinTarget: permitJoinTarget,
restartRequired: restartRequired ?? self.restartRequired,
config: config ?? self.config
)
}

func copyUpdatingPermitJoin(enabled: Bool, timeout: Int?, target: String?) -> BridgeInfo {
BridgeInfo(
version: version,
commit: commit,
coordinator: coordinator,
network: network,
logLevel: logLevel,
permitJoin: enabled,
permitJoinTimeout: timeout,
permitJoinEnd: timeout.map { Int(Date().timeIntervalSince1970 * 1000) + ($0 * 1000) },
permitJoinTarget: target,
restartRequired: restartRequired,
config: config
)
}
}

struct BridgeConfig: Codable, Sendable, Equatable {
Expand Down
14 changes: 12 additions & 2 deletions Shellbee/Core/Models/Device.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ struct Device: Codable, Identifiable, Sendable, Equatable, Hashable {
var powerSource: String?
var modelId: String?
var manufacturer: String?
let interviewCompleted: Bool
let interviewing: Bool
var interviewCompleted: Bool
var interviewing: Bool
var softwareBuildId: String?
var dateCode: String?
var endpoints: [String: JSONValue]?
Expand All @@ -28,6 +28,16 @@ struct Device: Codable, Identifiable, Sendable, Equatable, Hashable {
return ints.isEmpty ? [1] : ints
}

/// Whether the device exposes the Zigbee Identify cluster as a writable
/// property. Z2M renders this as `{ "name": "identify", "type": "enum",
/// "property": "identify", "access": 2, "values": ["identify"] }`.
var supportsIdentify: Bool {
guard let exposes = definition?.exposes else { return false }
return exposes.flattened.contains { expose in
expose.property == "identify" && expose.isWritable
}
}

func hash(into hasher: inout Hasher) { hasher.combine(id) }
static func == (lhs: Device, rhs: Device) -> Bool { lhs.ieeeAddress == rhs.ieeeAddress }

Expand Down
42 changes: 38 additions & 4 deletions Shellbee/Core/Networking/ConnectionConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ struct ConnectionConfig: Codable, Sendable {
var basePath: String
var authToken: String?
var name: String? = nil
var allowInvalidCertificates: Bool = false

static let defaultPort = 8080

Expand All @@ -27,6 +28,7 @@ extension ConnectionConfig: Equatable, Hashable {
&& lhs.useTLS == rhs.useTLS
&& lhs.basePath == rhs.basePath
&& lhs.authToken == rhs.authToken
&& lhs.allowInvalidCertificates == rhs.allowInvalidCertificates
}

func hash(into hasher: inout Hasher) {
Expand All @@ -35,6 +37,7 @@ extension ConnectionConfig: Equatable, Hashable {
hasher.combine(useTLS)
hasher.combine(basePath)
hasher.combine(authToken)
hasher.combine(allowInvalidCertificates)
}

var webSocketURL: URL? {
Expand Down Expand Up @@ -106,7 +109,14 @@ extension ConnectionConfig {
}

var persistedSnapshot: PersistedSnapshot {
PersistedSnapshot(host: host, port: port, useTLS: useTLS, basePath: basePath, name: name)
PersistedSnapshot(
host: host,
port: port,
useTLS: useTLS,
basePath: basePath,
name: name,
allowInvalidCertificates: allowInvalidCertificates
)
}

static func persistToken(for config: ConnectionConfig) {
Expand Down Expand Up @@ -147,13 +157,36 @@ extension ConnectionConfig {
let useTLS: Bool
let basePath: String
let name: String?

init(host: String, port: Int, useTLS: Bool, basePath: String, name: String? = nil) {
let allowInvalidCertificates: Bool

init(
host: String,
port: Int,
useTLS: Bool,
basePath: String,
name: String? = nil,
allowInvalidCertificates: Bool = false
) {
self.host = host
self.port = port
self.useTLS = useTLS
self.basePath = basePath
self.name = name
self.allowInvalidCertificates = allowInvalidCertificates
}

enum CodingKeys: String, CodingKey {
case host, port, useTLS, basePath, name, allowInvalidCertificates
}

init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
host = try c.decode(String.self, forKey: .host)
port = try c.decode(Int.self, forKey: .port)
useTLS = try c.decode(Bool.self, forKey: .useTLS)
basePath = try c.decode(String.self, forKey: .basePath)
name = try c.decodeIfPresent(String.self, forKey: .name)
allowInvalidCertificates = try c.decodeIfPresent(Bool.self, forKey: .allowInvalidCertificates) ?? false
}

var connectionConfig: ConnectionConfig {
Expand All @@ -172,7 +205,8 @@ extension ConnectionConfig {
useTLS: useTLS,
basePath: basePath,
authToken: ConnectionTokenKeychain.shared.token(for: lookup.secretLookupKey),
name: name
name: name,
allowInvalidCertificates: allowInvalidCertificates
)
}
}
Expand Down
Loading
Loading