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
10 changes: 6 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,9 @@ directory:
`Docs/AGENT_SWIFT_GUIDELINES.md`). Agents may **read, create, and edit** these
and are expected to keep them accurate. Treat the feature spec as the source
of truth for behavior, and update it when a behavior change makes it stale.
- **Human-authored** — every other doc, e.g. `README.md` and `CONTRIBUTING.md`.
Agents may **read** these for context but must **never create or modify**
them; flag needed changes for the maintainer instead.
- **Human-authored** — every other doc, e.g. `README.md`. Agents may **read**
these for context but must **never create or modify** them; flag needed
changes for the maintainer instead.

The `AGENT_` prefix is the contract: it marks a file as safe for agents to
maintain. Any human-authored doc added without the prefix is automatically
Expand Down Expand Up @@ -141,6 +141,7 @@ when reminded:
| `-onboarding-completed` / `-onboarding-required` | Force the onboarding flag |
| `-seed-scenario=standard` | Active soft rule "Work Time" + upcoming "Sleep" |
| `-seed-scenario=hard-mode-active` | Active Hard Mode rule "Locked In" + upcoming "Sleep" |
| `-github-url=<url>` / `-website-url=<url>` | Override the Settings About links with deterministic URLs |

Use `XCUIApplication.launchOpenAppLock(...)` (UITestSupport.swift), which also
provides `app.element(_:)` for identifier lookup across element types and
Expand All @@ -155,7 +156,8 @@ them): `newRuleButton`, `ruleCard-<name>`, `ruleStatus-<name>`,
`maxOpensStepper(+Value)`, `commitRuleButton`, `doneButton`,
`toggleEnabledButton`, `deleteRuleButton`, `closeDetailButton`,
`detailRuleName`, `detailStatusLabel`, `detailRow-<label>`,
`hardModeLockedNotice`, onboarding: `onboardingContinueButton`,
`hardModeLockedNotice`, settings About links: `githubLinkButton` /
`websiteLinkButton`, onboarding: `onboardingContinueButton`,
`allowScreenTimeButton`, `permissionDeniedLabel`, `openSettingsButton`.

Gotchas learned the hard way:
Expand Down
2 changes: 1 addition & 1 deletion Config/DeveloperSettings.sample.xcconfig
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
// Note: the app and its Screen Time extensions are registered under the
// `dev.bchen.OpenAppLock` bundle-ID prefix and the `group.dev.bchen.OpenAppLock`
// App Group, which belong to the maintainer's team. To run on your own device
// you must also change those identifiers to your own — see CONTRIBUTING.md.
// you must also change those identifiers to your own.
// Building and running the tests in the simulator needs no signing at all.

DEVELOPMENT_TEAM = YOUR_TEAM_ID
18 changes: 17 additions & 1 deletion Config/Shared.xcconfig
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,27 @@
// The optional include below is a no-op when that file is absent (a fresh
// clone, or CI), so the project always opens and builds for the simulator. To
// produce a signed build or run on a device, create that file from
// Config/DeveloperSettings.sample.xcconfig — see CONTRIBUTING.md.
// Config/DeveloperSettings.sample.xcconfig (it has step-by-step instructions).

// Fallback defaults. These are overridden by DeveloperSettings.xcconfig when
// it is present, because the include below comes after them.
DEVELOPMENT_TEAM =
CODE_SIGN_STYLE = Automatic

// --- App metadata links ------------------------------------------------------
// Outbound URLs surfaced on the Settings screen (GitHub repo + marketing site).
// They are user-defined build settings so the destinations are configurable
// without touching code; OpenAppLock/Info.plist maps them to the OALGitHubURL /
// OALWebsiteURL keys (custom INFOPLIST_KEY_* settings are NOT injected into a
// generated Info.plist, so the values have to ride through a real plist), and
// the app reads them at runtime through `AppLinks`. Edit these to point at the
// real links — the website value is a placeholder until the site exists.
//
// xcconfig treats "//" as the start of a comment, so a literal "https://…"
// would be truncated to "https:". `$(SLASH)` (a single "/") lets us spell the
// "://" without tripping the comment parser; it expands at build time.
SLASH = /
OAL_GITHUB_URL = https:$(SLASH)$(SLASH)github.com/brendan-ch/OpenAppLock
OAL_WEBSITE_URL = https:$(SLASH)$(SLASH)openapplock.app

#include? "../../SharedXcodeSettings/DeveloperSettings.xcconfig"
27 changes: 25 additions & 2 deletions Docs/AGENT_RULES_FEATURE_SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -528,7 +528,7 @@ own `NavigationStack`:

```
TabView: [Home] [Rules] [Settings]
│ │ └── "Uninstall Protection" toggle + "Manage App Lists" ─▶ App List library (management mode)
│ │ └── "Uninstall Protection" toggle + "Manage App Lists" ─▶ App List library (management mode) + "About" GitHub / Website links
│ └── rules grouped into Schedule / Time Limit / Open Limit sections; "+" ─▶ New Rule sheet
│ └── tap a rule row ─▶ Rule Detail sheet ─▶ "Edit Rule" ─▶ Rule Editor
└── "Currently Blocking" section + "Usage" section
Expand All @@ -542,7 +542,7 @@ it runs regardless of the selected tab.
|---|---|
| Home tab | `NavigationStack` + `List`. Every row carries a **leading kind icon**, the name, and a `<Type> · <context>` subtitle, where *context* is the rule's live status: a schedule reads its countdown (`Schedule · 6h left`), a limit reads its usage once used today (`Time Limit · 18m of 45m used`) or its plain budget while untouched (`Time Limit · 45m / day`). **"Currently Blocking"** section (renamed from "Blocked Apps") — the *rules* blocking right now: a Hard Mode rule shows a trailing `lock.fill` (the block can't be lifted), a soft rule shows a trailing "Unblock" button; tapping a hard row shows the "Hard Mode is on" alert, a soft row the unblock dialog. A limit rule whose budget is **spent** appears here (moved out of Usage). **"Usage"** section: every enabled limit rule scheduled today that is *not* currently blocking; rows have **no trailing label** (the context lives in the subtitle). |
| Rules tab | `NavigationStack` + `List` split into **Schedule / Time Limit / Open Limit** sections (empty sections hidden); **rules are list rows** (leading kind icon, name, and a `<context>` subtitle — the same live status/countdown/usage as Home, but **without the type prefix** since the section header already conveys the kind, and **without a separate trailing status label**; the `ruleStatus-<name>` identifier lives on this subtitle); "+" toolbar button opens the New Rule sheet; tapping a row opens the Rule Detail sheet. |
| Settings tab | `NavigationStack` + `Form`. **Uninstall Protection** toggle — while on, the device's app-removal is denied (`ManagedSettingsStore.application.denyAppRemoval`) whenever any Hard Mode rule is actively blocking. The toggle itself is **locked while any Hard Mode rule is actively blocking**: the switch is replaced by a trailing red `lock.fill` (same treatment as a Home "Currently Blocking" hard row) so the protection can't be turned off mid-block — its whole purpose. **Manage App Lists** pushes the shared App List library in management mode (create / edit / delete, honoring the Hard Mode lock — same flow as the rule editor's picker, minus selection). |
| Settings tab | `NavigationStack` + `Form`. **Uninstall Protection** toggle — while on, the device's app-removal is denied (`ManagedSettingsStore.application.denyAppRemoval`) whenever any Hard Mode rule is actively blocking. The toggle itself is **locked while any Hard Mode rule is actively blocking**: the switch is replaced by a trailing red `lock.fill` (same treatment as a Home "Currently Blocking" hard row) so the protection can't be turned off mid-block — its whole purpose. **Manage App Lists** pushes the shared App List library in management mode (create / edit / delete, honoring the Hard Mode lock — same flow as the rule editor's picker, minus selection). An **About** section holds outbound `Link` rows — **GitHub** and **Website** — each shown only when its destination is configured (see §6.2). |
| Rule detail | Sheet with inline nav title (name + "Schedule, 6h left" caption), `LabeledContent` rows, "Edit Rule" row pushes the editor; hard-locked rules show a lock row instead |
| New Rule | `List` with a "Rule Type" section and preset sections as plain rows; editor pushed via `navigationDestination(item:)` |
| Rule editor | Native `Form`: an inline **Name text field** at the top (no separate rename button; empty names fall back to the kind default), `DatePicker` rows, full-width day-circle row (≥44pt tap targets) with the summary in the section header, toggle rows with footers, stepper rows. Both modes commit via a **checkmark** in the navigation bar (labels: "Add Rule" / "Done"; replaces Hold to Commit). In edit mode an **ellipsis menu** ("Rule Actions") next to the checkmark holds Disable Rule and the destructive Delete Rule |
Expand Down Expand Up @@ -588,3 +588,26 @@ an escape hatch.

Like all Screen Time behavior, the real device effect is only observable on a
device (the simulator uses mock shields and delivers no DeviceActivity callbacks).

### 6.2 About links (Settings)

The Settings "About" section offers two outbound `Link` rows — **GitHub**
(`githubLinkButton`) and **Website** (`websiteLinkButton`) — that open the
project's repository and marketing site in the browser.

Both destinations are **configuration, not code**. They come from the
`OAL_GITHUB_URL` / `OAL_WEBSITE_URL` user-defined build settings in
`Config/Shared.xcconfig`, ride into the app through `OpenAppLock/Info.plist`
(keys `OALGitHubURL` / `OALWebsiteURL` — custom keys can't be injected into a
*generated* Info.plist, so a real partial plist carries them), and are read at
runtime through the single accessor `AppLinks`. Point them at the real links by
editing the build settings; the website value is a placeholder until the site
exists. A row whose URL is unset, blank, or unparseable is omitted, so the
section is empty (and hidden) only when neither link is configured.

`AppLinks.url(from:)` is the pure parse seam (trims, rejects blank/non-string);
unit tests cover it and the full build-setting → Info.plist pipeline. A UI test
asserts each button opens the configured URL by intercepting the `openURL`
action in UI-testing mode (so no browser launches) — the launch arguments
`-github-url=` / `-website-url=` (parsed by `LaunchConfiguration`) feed it
deterministic URLs decoupled from the committed build settings.
14 changes: 14 additions & 0 deletions OpenAppLock.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,13 @@
/* End PBXFileReference section */

/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
AB00000000000000000000B0 /* Exceptions for "OpenAppLock" folder in "OpenAppLock" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
);
target = 20A7EDCD2E47B7CD0097608D /* OpenAppLock */;
};
AB00000000000000000000B1 /* Exceptions for "OpenAppLockMonitor" folder in "OpenAppLockMonitor" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Expand All @@ -105,6 +112,9 @@
/* Begin PBXFileSystemSynchronizedRootGroup section */
20A7EDD02E47B7CD0097608D /* OpenAppLock */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
AB00000000000000000000B0 /* Exceptions for "OpenAppLock" folder in "OpenAppLock" target */,
);
path = OpenAppLock;
sourceTree = "<group>";
};
Expand Down Expand Up @@ -673,6 +683,7 @@
ENABLE_PREVIEWS = YES;
ENABLE_USER_SELECTED_FILES = readonly;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = OpenAppLock/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = OpenAppLock;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
Expand All @@ -690,6 +701,7 @@
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 15.6;
MARKETING_VERSION = 1.0.0;
OAL_WEBSITE_URL = "https:$(SLASH)$(SLASH)openapplock.bchen.dev";
PRODUCT_BUNDLE_IDENTIFIER = dev.bchen.OpenAppLock;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
Expand Down Expand Up @@ -717,6 +729,7 @@
ENABLE_PREVIEWS = YES;
ENABLE_USER_SELECTED_FILES = readonly;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = OpenAppLock/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = OpenAppLock;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
Expand All @@ -734,6 +747,7 @@
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 15.6;
MARKETING_VERSION = 1.0.0;
OAL_WEBSITE_URL = "https:$(SLASH)$(SLASH)openapplock.bchen.dev";
PRODUCT_BUNDLE_IDENTIFIER = dev.bchen.OpenAppLock;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
Expand Down
16 changes: 16 additions & 0 deletions OpenAppLock/Info.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Partial Info.plist: GENERATE_INFOPLIST_FILE=YES still merges Xcode's
generated keys (scene manifest, orientations, …) on top of this file.
Only the app-specific keys that can't be expressed as INFOPLIST_KEY_*
build settings live here. The two link keys are fed from the
OAL_GITHUB_URL / OAL_WEBSITE_URL build settings (Config/Shared.xcconfig)
and read at runtime through AppLinks. -->
<key>OALGitHubURL</key>
<string>$(OAL_GITHUB_URL)</string>
<key>OALWebsiteURL</key>
<string>$(OAL_WEBSITE_URL)</string>
</dict>
</plist>
40 changes: 40 additions & 0 deletions OpenAppLock/Services/AppLinks.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
//
// AppLinks.swift
// OpenAppLock
//

import Foundation

/// The one place the app reads its outbound URLs (GitHub repo, marketing site).
///
/// The values are *configuration, not code*: they come from the
/// `OAL_GITHUB_URL` / `OAL_WEBSITE_URL` user-defined build settings (see
/// `Config/Shared.xcconfig`), surfaced to the runtime through the generated
/// `Info.plist` via `INFOPLIST_KEY_OALGitHubURL` / `INFOPLIST_KEY_OALWebsiteURL`.
/// Point them at the real destinations by editing the build settings — no code
/// change needed.
///
/// Read them anywhere via ``gitHub`` / ``website``. Both are `nil` when the
/// setting is unset, blank, or unparseable, so callers can simply omit the row.
enum AppLinks {
/// Info.plist keys the build settings feed into (kept in sync with
/// `INFOPLIST_KEY_*` in `Config/Shared.xcconfig`).
static let gitHubInfoKey = "OALGitHubURL"
static let websiteInfoKey = "OALWebsiteURL"

/// The project's GitHub repository, or `nil` when unconfigured.
static var gitHub: URL? { url(from: Bundle.main.object(forInfoDictionaryKey: gitHubInfoKey)) }

/// The project's marketing website, or `nil` when unconfigured.
static var website: URL? { url(from: Bundle.main.object(forInfoDictionaryKey: websiteInfoKey)) }

/// Turns a raw Info.plist value into a usable URL. Pure and side-effect
/// free so it can be unit-tested directly: non-string, blank, or
/// whitespace-only values (and anything `URL` can't parse) become `nil`.
static func url(from rawValue: Any?) -> URL? {
guard let string = rawValue as? String else { return nil }
let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
return URL(string: trimmed)
}
}
14 changes: 14 additions & 0 deletions OpenAppLock/Services/LaunchConfiguration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,18 @@ struct LaunchConfiguration: Equatable {
/// Forces the onboarding-completed flag at launch. Nil leaves stored state alone.
var onboardingCompleted: Bool?
var seedScenario: SeedScenario?
/// Overrides the Settings GitHub link so UI tests open a known URL instead of
/// the configured (swappable) build-setting value. Nil uses `AppLinks`.
var gitHubURLOverride: String?
/// Overrides the Settings website link for UI tests. Nil uses `AppLinks`.
var websiteURLOverride: String?

static let uiTestingFlag = "-ui-testing"
static let onboardingCompletedFlag = "-onboarding-completed"
static let onboardingRequiredFlag = "-onboarding-required"
static let seedScenarioPrefix = "-seed-scenario="
static let gitHubURLPrefix = "-github-url="
static let websiteURLPrefix = "-website-url="

static func parse(arguments: [String]) -> LaunchConfiguration {
var config = LaunchConfiguration()
Expand All @@ -42,8 +49,15 @@ struct LaunchConfiguration: Equatable {
rawValue: String(seedArgument.dropFirst(seedScenarioPrefix.count))
)
}
config.gitHubURLOverride = value(in: arguments, prefix: gitHubURLPrefix)
config.websiteURLOverride = value(in: arguments, prefix: websiteURLPrefix)
return config
}

/// Returns the suffix of the first `prefix=value` argument, or nil if absent.
private static func value(in arguments: [String], prefix: String) -> String? {
arguments.first { $0.hasPrefix(prefix) }.map { String($0.dropFirst(prefix.count)) }
}

static let current = parse(arguments: ProcessInfo.processInfo.arguments)
}
Loading
Loading