Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
45 changes: 45 additions & 0 deletions .github/workflows/contract-sync.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
name: API contract sync

# Keeps the vendored API contract and the screen registry current, so the app's generated client, its routes
# and the help center all describe the same API. Opens a pull request when either one changed upstream.
on:
schedule:
- cron: "37 6 * * *"
repository_dispatch:
types: [api-contract-updated, specs-updated]
workflow_dispatch:

permissions:
contents: write
pull-requests: write

jobs:
sync:
runs-on: macos-26
steps:
- uses: actions/checkout@v5

- name: Fetch the latest published openapi.yaml
run: scripts/sync-openapi.sh

- name: Regenerate the screen registry from the specs
run: |
git clone --depth 1 https://github.com/CompositeCode/substratalapps-applications-specs.git "$RUNNER_TEMP/specs"
scripts/generate-screen-ids.py "$RUNNER_TEMP/specs/substratal-application-specs"

- name: Open a pull request if anything changed
env:
GH_TOKEN: ${{ github.token }}
run: |
if git diff --quiet -- api-contract Packages/SubstratalKit/Sources; then
echo "Contract and screens are current."; exit 0
fi
branch="chore/contract-sync-$(date -u +%Y%m%d)"
version=$(python3 -c "import json;print(json.load(open('api-contract/CONTRACT.json'))['info_version'])")
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git checkout -B "$branch"
git add api-contract Packages/SubstratalKit/Sources
git commit -m "Sync API contract ($version) and screen registry"
git push --force-with-lease origin "$branch"
gh pr create --fill --base main --head "$branch" || gh pr edit "$branch" --body "Updated by the API contract sync workflow."
32 changes: 32 additions & 0 deletions .github/workflows/docs-dispatch.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
name: Help center rebuild

# The help center lives in CompositeCode/substratalapps-applications-specs and is shared by every Hub app.
# When this app's main branch changes, ask it to rebuild so the published help reflects what shipped.
# Needs the secret SPECS_DOCS_TOKEN: a fine-grained token with Contents: read and write on the specs repo.
on:
push:
branches: [main]
paths:
- "App/**"
- "Packages/**"
- "api-contract/**"
- "docs/**"
workflow_dispatch:

jobs:
dispatch:
runs-on: ubuntu-latest
steps:
- name: Notify the help center
env:
GH_TOKEN: ${{ secrets.SPECS_DOCS_TOKEN }}
run: |
if [ -z "$GH_TOKEN" ]; then
echo "::warning::SPECS_DOCS_TOKEN isn't set, so the help center will pick this up on its daily build instead."
exit 0
fi
gh api repos/CompositeCode/substratalapps-applications-specs/dispatches \
-f event_type=app-docs-updated \
-F "client_payload[app]=ios" \
-F "client_payload[repository]=$GITHUB_REPOSITORY" \
-F "client_payload[sha]=$GITHUB_SHA"
39 changes: 39 additions & 0 deletions .github/workflows/ios.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
name: iOS

# Builds and tests on every push and pull request. Also checks that the generated Xcode project matches
# project.yml, so nobody hand-edits project.pbxproj.
on:
push:
branches: [main]
pull_request:
workflow_dispatch:

concurrency:
group: ios-${{ github.ref }}
cancel-in-progress: true

jobs:
build-and-test:
runs-on: macos-26
timeout-minutes: 45
steps:
- uses: actions/checkout@v5

- name: Select Xcode
run: sudo xcode-select -s /Applications/Xcode_27.0.app || xcodebuild -version

- name: Project matches project.yml
run: |
brew install xcodegen
xcodegen generate --quiet
git diff --exit-code -- SubstratalHub.xcodeproj

- name: SubstratalKit unit tests
working-directory: Packages/SubstratalKit
run: swift test

- name: Build and UI smoke test
run: |
xcodebuild -project SubstratalHub.xcodeproj -scheme SubstratalHub -skipPackagePluginValidation \
-destination 'platform=iOS Simulator,name=iPhone 17 Pro' \
test -only-testing:SubstratalHubUITests
45 changes: 45 additions & 0 deletions App/AppEnvironment.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import SwiftUI
import SubstratalCore
import SubstratalUI

/// Builds the object graph once per launch. Pass `-demo YES` (or pick "Demo data" on the sign-in screen
/// in debug builds) to run against fixtures with no network.
@MainActor
struct AppEnvironment {
let configuration: APIConfiguration
let session: SessionModel
let versionMonitor: APIVersionMonitor
let theme: ThemeStore
let navigator: Navigator

static func launch() -> AppEnvironment {
let wantsDemo = UserDefaults.standard.bool(forKey: "demo") || ProcessInfo.processInfo.arguments.contains("-uiTesting")
return make(wantsDemo ? .demo : .production)
}

static func make(_ environment: APIConfiguration.Environment) -> AppEnvironment {
let appVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0"
let configuration = APIConfiguration.make(environment, appVersion: appVersion, osVersion: UIDevice.current.systemVersion)
let monitor = APIVersionMonitor()
let session: SessionModel
switch environment {
case .demo:
session = SessionModel(service: DemoHubService(), tokens: nil)
case .production, .localDevserver:
let tokens = TokenStore()
session = SessionModel(service: LiveHubService(configuration: configuration, tokens: tokens, versionMonitor: monitor), tokens: tokens)
}
return AppEnvironment(configuration: configuration, session: session, versionMonitor: monitor, theme: ThemeStore(), navigator: Navigator())
}
}

private struct AppConfigurationKey: EnvironmentKey {
static let defaultValue = APIConfiguration.make(.demo, appVersion: "0", osVersion: "0")
}

extension EnvironmentValues {
var appConfiguration: APIConfiguration {
get { self[AppConfigurationKey.self] }
set { self[AppConfigurationKey.self] = newValue }
}
}
125 changes: 125 additions & 0 deletions App/Features/Account/AccountView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import SwiftUI
import SubstratalCore
import SubstratalUI

/// ACC-01 Account & profile, plus the compact layout's home for Plan & Billing, Settings and Help.
struct AccountView: View {
@Environment(SessionModel.self) private var session
@Environment(\.appConfiguration) private var configuration
@Environment(\.openURL) private var openURL
@State private var confirmSignOutEverywhere = false

var body: some View {
List {
if let user = session.user {
Section {
HStack(spacing: 14) {
Text(user.initials)
.font(.title3.weight(.semibold))
.frame(width: 52, height: 52)
.background(.tint.opacity(0.15), in: Circle())
VStack(alignment: .leading, spacing: 4) {
Text(user.email).font(.headline)
HStack {
StatusBadge(user.plan == "standard" ? .active : .none, label: user.plan == "standard" ? "Standard" : "Free")
if user.mfaEnabled { StatusBadge(.active, label: "Two-factor on") }
}
}
}
IDChip(user.id)
}
}
Section {
NavigationLink(value: Route(.acc04)) { Label("Security", systemImage: "lock.shield") }
NavigationLink(value: Route(.ntf03)) { Label("Devices & notifications", systemImage: "iphone.gen3") }
NavigationLink(value: Route(.acc07)) { Label("Privacy", systemImage: "hand.raised") }
}
Section {
NavigationLink(value: Route(.bil01)) { Label("Plan & Billing", systemImage: "creditcard") }
NavigationLink(value: Route(.set01)) { Label("Settings", systemImage: "gearshape") }
}
Section("Help & documentation") {
Button { openURL(HelpCenter.home) } label: { Label("Help center", systemImage: "questionmark.circle") }
Button { openURL(HelpCenter.tutorials) } label: { Label("Tutorials", systemImage: "graduationcap") }
Button { openURL(HelpCenter.faq) } label: { Label("FAQ", systemImage: "text.bubble") }
Button { openURL(HelpCenter.apiReference) } label: { Label("API reference (v\(APIConfiguration.apiVersion))", systemImage: "curlybraces") }
Button { openURL(HelpCenter.whatsNew) } label: { Label("What's new", systemImage: "sparkles") }
}
Section("About") {
LabeledContent("App version", value: Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "–")
LabeledContent("API", value: configuration.baseURL.host() ?? "")
LabeledContent("API version (Substratal-Version)", value: APIConfiguration.apiVersion)
LabeledContent("Contract", value: SubstratalCore.contractVersion)
}
Section {
Button("Sign out", role: .destructive) { Task { await session.signOut() } }
Button("Sign out everywhere…", role: .destructive) { confirmSignOutEverywhere = true }
}
}
.navigationTitle("Account")
.confirmationDialog("Sign out on every device?", isPresented: $confirmSignOutEverywhere, titleVisibility: .visible) {
Button("Sign out everywhere", role: .destructive) { Task { await session.signOut(everywhere: true) } }
} message: {
Text("Every session you have, on every device, ends now.")
}
}
}

/// SET-01 Settings — Global: appearance and theme choice (THEMES.md). Other settings rows come with the full screen.
struct AppearanceSettingsView: View {
@Environment(ThemeStore.self) private var theme

var body: some View {
@Bindable var theme = theme
Form {
Section("Appearance") {
Picker("Appearance", selection: $theme.mode) {
Text("System").tag(ThemeMode.system)
Text("Light").tag(ThemeMode.light)
Text("Dark").tag(ThemeMode.dark)
}
.pickerStyle(.segmented)
}
Section("Light theme") {
ThemeSwatchGrid(options: LightTheme.allCases.map { ($0.id, $0.displayName, $0.palette) }, selection: theme.light.id) {
theme.light = LightTheme(rawValue: $0) ?? .default
}
}
Section("Dark theme") {
ThemeSwatchGrid(options: DarkTheme.allCases.map { ($0.id, $0.displayName, $0.palette) }, selection: theme.dark.id) {
theme.dark = DarkTheme(rawValue: $0) ?? .default
}
}
}
.navigationTitle("Settings")
}
}

private struct ThemeSwatchGrid: View {
let options: [(id: String, name: String, palette: ThemePalette)]
let selection: String
let select: (String) -> Void

var body: some View {
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 10), count: 3), spacing: 10) {
ForEach(options, id: \.id) { option in
Button { select(option.id) } label: {
VStack(spacing: 6) {
RoundedRectangle(cornerRadius: 10)
.fill(option.palette.background)
.overlay(alignment: .bottomTrailing) {
Circle().fill(option.palette.accent).frame(width: 18, height: 18).padding(6)
}
.overlay(RoundedRectangle(cornerRadius: 10).strokeBorder(option.id == selection ? option.palette.accent : .secondary.opacity(0.3), lineWidth: option.id == selection ? 3 : 1))
.frame(height: 54)
Text(option.name).font(.caption).lineLimit(1)
}
}
.buttonStyle(.plain)
.accessibilityLabel(option.name)
.accessibilityAddTraits(option.id == selection ? .isSelected : [])
}
}
.padding(.vertical, 4)
}
}
82 changes: 82 additions & 0 deletions App/Features/Applications/ApplicationsView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import SwiftUI
import SubstratalCore
import SubstratalUI

/// APL-01 Applications.
struct ApplicationsView: View {
@Environment(SessionModel.self) private var session
@State private var state: LoadState<[ApplicationSummary]> = .idle

var body: some View {
LoadStateView(state, retry: { Task { await load() } }) { apps in
List(apps) { app in
NavigationLink(value: Route(.apl03, ["appId": app.id])) {
HStack(spacing: 12) {
AppIconPlaceholder(name: app.name)
VStack(alignment: .leading, spacing: 3) {
Text(app.name).font(.body.weight(.medium))
HStack(spacing: 6) {
StatusBadge(app.reviewStatus)
Text("\(app.visibility) · \(app.accessTier)").font(.caption).foregroundStyle(.secondary)
}
}
}
}
}
.refreshable { await load() }
}
.navigationTitle("Applications")
.toolbar {
ToolbarItem(placement: .primaryAction) {
NavigationLink(value: Route(.apl02)) { Label("Register app", systemImage: "plus") }
.disabled(!(session.user?.emailVerified ?? false))
}
}
.task { await load() }
}

private func load() async {
do { state = .loaded(try await session.service.applications(cursor: nil).items) }
catch let error as APIError { state = .failed(error) } catch {}
}
}

/// APL-03 Application — Overview, with links to its tabs (settings schema, roles, push, keys, webhooks).
struct ApplicationDetailView: View {
let appID: String
@Environment(SessionModel.self) private var session
@State private var app: ApplicationSummary?

var body: some View {
List {
if let app {
Section {
HStack(spacing: 12) {
AppIconPlaceholder(name: app.name)
VStack(alignment: .leading) {
Text(app.name).font(.title3.weight(.semibold))
StatusBadge(app.reviewStatus)
}
}
if let summary = app.summary { Text(summary).foregroundStyle(.secondary) }
IDChip(app.id)
}
Section("Catalog") {
LabeledContent("Slug", value: app.slug)
LabeledContent("Visibility", value: app.visibility.capitalized)
LabeledContent("Access tier", value: app.accessTier.capitalized)
}
}
Section("Configure") {
NavigationLink(value: Route(.apl04, ["appId": appID])) { Label("Settings schema", systemImage: "slider.horizontal.3") }
NavigationLink(value: Route(.apl05, ["appId": appID])) { Label("Permissions & roles", systemImage: "checkmark.shield") }
NavigationLink(value: Route(.apl07, ["appId": appID])) { Label("Push credentials", systemImage: "bell.badge") }
if session.capabilities.canManageApplications {
NavigationLink(value: Route(.apl06, ["appId": appID])) { Label("Review", systemImage: "checkmark.seal") }
}
}
}
.navigationTitle(app?.name ?? "Application")
.task { app = try? await session.service.applications(cursor: nil).items.first { $0.id == appID } }
}
}
Loading
Loading