From 80a1db0d6dedffc8fb7cf7f270b7bc5589084792 Mon Sep 17 00:00:00 2001 From: Chris Beiser Date: Sun, 26 Apr 2026 16:43:29 -1000 Subject: [PATCH 1/4] Improvements to builds --- .gitignore | 1 + .swiftlint-autofix.yml | 43 +++++++ .swiftlint-build.yml | 1 + .swiftlint.yml | 61 +++++++++ AGENTS.md | 61 +++++++++ orion/OrionApp.swift | 2 +- scripts/swiftlint.sh | 62 +++++++++ scripts/time-build.sh | 44 +++++++ scripts/verify-fast.sh | 36 ++++++ scripts/verify-full.sh | 36 ++++++ solipsistweets.xcodeproj/project.pbxproj | 118 ++++++++++++++---- .../xcschemes/Verify Full.xcscheme | 72 +++++++++++ solipsistweets/ContentView.swift | 51 +++++--- solipsistweets/OnScreenTimeTracker.swift | 9 +- solipsistweets/SiteProfile.swift | 13 +- solipsistweets/solipsistweetsApp.swift | 4 +- 16 files changed, 565 insertions(+), 49 deletions(-) create mode 100644 .swiftlint-autofix.yml create mode 100644 .swiftlint-build.yml create mode 100644 .swiftlint.yml create mode 100644 AGENTS.md create mode 100755 scripts/swiftlint.sh create mode 100755 scripts/time-build.sh create mode 100755 scripts/verify-fast.sh create mode 100755 scripts/verify-full.sh create mode 100644 solipsistweets.xcodeproj/xcshareddata/xcschemes/Verify Full.xcscheme diff --git a/.gitignore b/.gitignore index b706f63..2a704c5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .context/ +DerivedData/ # Xcode user-specific files *.xcuserstate diff --git a/.swiftlint-autofix.yml b/.swiftlint-autofix.yml new file mode 100644 index 0000000..f707d0e --- /dev/null +++ b/.swiftlint-autofix.yml @@ -0,0 +1,43 @@ +excluded: + - DerivedData + - .context + +only_rules: + - attribute_name_spacing + - closing_brace + - closure_end_indentation + - closure_spacing + - colon + - comma + - comma_inheritance + - comment_spacing + - control_statement + - duplicate_imports + - empty_enum_arguments + - empty_parameters + - empty_parentheses_with_trailing_closure + - function_name_whitespace + - implicit_optional_initialization + - leading_whitespace + - literal_expression_end_indentation + - modifier_order + - no_space_in_method_call + - opening_brace + - operator_usage_whitespace + - period_spacing + - protocol_property_accessors_order + - redundant_discardable_let + - redundant_void_return + - return_arrow_whitespace + - sorted_imports + - statement_position + - trailing_comma + - trailing_newline + - trailing_semicolon + - trailing_whitespace + - unneeded_parentheses_in_closure_argument + - vertical_whitespace + - vertical_whitespace_between_cases + - vertical_whitespace_closing_braces + - vertical_whitespace_opening_braces + - void_return diff --git a/.swiftlint-build.yml b/.swiftlint-build.yml new file mode 100644 index 0000000..61b17cb --- /dev/null +++ b/.swiftlint-build.yml @@ -0,0 +1 @@ +parent_config: .swiftlint.yml diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 0000000..e17ccd7 --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,61 @@ +reporter: xcode +strict: true + +included: + - solipsistweets + - orion + +excluded: + - DerivedData + - .context + +disabled_rules: + # Broad shape limits are intentionally disabled so safety checks stay useful. + - cyclomatic_complexity + - file_length + - function_body_length + - function_parameter_count + - identifier_name + - large_tuple + - line_length + - nesting + - type_body_length + + # Keep style-only noise out of strict build gates unless existing code is + # baselined or fixed separately. + - colon + - comma + - function_name_whitespace + - no_space_in_method_call + - opening_brace + - statement_position + - switch_case_alignment + - trailing_comma + - trailing_newline + - trailing_whitespace + - vertical_whitespace + +opt_in_rules: + - contains_over_filter_count + - contains_over_filter_is_empty + - discarded_notification_center_observer + - empty_count + - empty_string + - fallthrough + - fatal_error_message + - force_unwrapping + - implicitly_unwrapped_optional + - identical_operands + - no_empty_block + - private_action + - private_outlet + - private_subject + - private_swiftui_state + - redundant_nil_coalescing + - toggle_bool + - unhandled_throwing_task + - unneeded_escaping + - unneeded_throws_rethrows + - unowned_variable_capture + - weak_delegate + - yoda_condition diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..9d63954 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,61 @@ +# AGENTS.md + +## Build And Verification + +Use the fastest verification script that covers the files you touched. + +Use Verify Fast (`scripts/verify-fast.sh`) for changes isolated to one app target. Set `SCHEME=orion` when the change only needs the Orion target; otherwise the script defaults to `solipsistweets`. + +Use Verify Full (`scripts/verify-full.sh`) when a change touches shared Swift files under `solipsistweets/`, project settings, build scripts, assets used by both apps, or anything that should compile for both app schemes. It builds the shared `Verify Full` scheme, which compiles both app targets in one Xcode invocation. This repository has two app schemes, `solipsistweets` and `orion`, and no watch target. + +For production-scheme validation such as app packaging, project wiring, signing-sensitive behavior, platform support, or release-only settings, run the affected shared Xcode scheme directly with the needed destination/configuration. There is no separate watch/full verification script in this repo. + +All verification scripts use repo-local `DerivedData/`, pass `-disableAutomaticPackageResolution`, disable the compiler index store with `COMPILER_INDEX_STORE_ENABLE=NO`, disable debug dylib generation with `ENABLE_DEBUG_DYLIB=NO`, disable testability with `ENABLE_TESTABILITY=NO`, disable previews/string-symbol/localized-string generation, and use simulator-only no-signing settings `CODE_SIGNING_ALLOWED=NO` plus `CODE_SIGN_STYLE=Manual`. This repo currently has no SwiftPM package dependencies; do not add package-resolution/cache infrastructure unless dependencies are introduced. + +For simulator builds, the app-only and fast verification scripts constrain `ARCHS` to the host architecture by default to avoid building unused simulator slices. Override with `BUILD_ARCHS=...` only when broader simulator architecture coverage is intentional. + +For build timing, use `scripts/time-build.sh`. It keeps timing build products under `.context/build-timing/` and enables Xcode's build timing summary. By default it times a clean `build` of the `Verify Full` scheme; set `SCHEME=orion`, `BUILD_ACTION=build-for-testing`, or `CLEAN=0` when needed. + +## Tests + +This repo does not maintain unit-test or UI-test targets, and tests are not part of routine development here. Do not add test targets, test schemes, test plans, XCTest/Testing dependencies, or test source folders as part of normal changes. + +New code still needs verification. Use the fastest verification script that covers the files touched, and broaden to Verify Full (`scripts/verify-full.sh`) when the change affects shared code, project settings, or both app targets. + +If a future change truly requires executable tests, first document why build, lint, and manual verification are insufficient and why the behavior can be tested with lower risk than leaving it untested. Keep that testing infrastructure scoped to the need and update this guidance in the same change. + +## Project Settings + +Keep app targets on Swift 6 language mode with warnings as errors, complete strict concurrency, strict memory safety, and explicit `any` existential checking. Do not weaken these settings to make a change compile; fix the source issue or document why a temporary exception is required. + +Keep fast-build overhead low without bypassing lint: Xcode target lint phases run `scripts/swiftlint.sh build` with explicit config/script/source-directory inputs and stamp outputs so Xcode controls dependency analysis. Verification scripts apply build-test overrides to disable previews, testability, generated string catalog symbols, and localized string emission; real app schemes keep the Moods-style real-build settings. + +Keep launch schemes useful for debugging. Shared launch schemes should reduce system log noise and make invalid geometry reports actionable when possible. + +Release app products should validate products during build. Keep `VALIDATE_PRODUCT = YES` for production release targets. + +Avoid broad platform expansion unless the product intentionally supports it. This repo supports iOS/iPadOS only; do not add Mac Catalyst, Designed for iPhone/iPad on Mac, visionOS, or XR support by accident while editing target settings. + +Keep `ENABLE_USER_SCRIPT_SANDBOXING = NO`; project build phases may need access that Xcode's user script sandbox blocks. + +## DerivedData + +Keep DerivedData local to the worktree at `DerivedData/`. It is ignored by Git. + +## Lint + +SwiftLint is configured with focused safety/correctness rules in `.swiftlint.yml` and runs in strict mode. Broad size/name/shape rules and current style-only noise are disabled so formatting preferences do not drown out safety checks or block routine builds. + +Run build-time lint with `scripts/swiftlint.sh build`; Xcode target phases run the same command during verification builds. Missing SwiftLint is a local warning but a CI error. Run the base config directly with `scripts/swiftlint.sh lint`. Run autofix-only style cleanup with `scripts/swiftlint.sh fix`. Keep broad style gates out of strict lint unless existing code is baselined or fixed separately. + +## Source Practices + +Prefer typed boundary models over raw dictionaries or half-decoded payloads. Use `String?` only when absence has meaning, keep one source of truth for mutable state, and prefer structs/enums unless identity, shared mutable state, UIKit inheritance, or Objective-C interop requires a class. + +Use weak captures in long-lived closures, Combine sinks, timers, animation completions, and tasks that capture UI or store objects. Keep UI mutation on the main actor with `await MainActor.run { ... }` or `Task { @MainActor [weak self] in ... }`. + +Treat app-owned static assets and parser setup as invariants. Fail fast when required named assets or static regular expressions are missing or invalid; handle user and server data as recoverable input. + +For manual UIKit layout, do size-dependent work in `layoutSubviews()` or after `viewDidLayoutSubviews()`, ask subviews for size with `sizeThatFits(_:)`, prefer `bounds.size` plus `center`, and centralize padding and spacing constants. + +Treat stale documentation as a correctness bug. When a change alters architecture, ownership, build behavior, invariants, or platform constraints, update the relevant repo documentation in the same change. diff --git a/orion/OrionApp.swift b/orion/OrionApp.swift index c755ef0..2379840 100644 --- a/orion/OrionApp.swift +++ b/orion/OrionApp.swift @@ -5,7 +5,7 @@ struct OrionApp: App { @Environment(\.scenePhase) private var scenePhase @State private var requestedURL: URL = RedditSiteProfile().startURL @StateObject private var screenTimeTracker = OnScreenTimeTracker() - private let profile: SiteProfile = RedditSiteProfile() + private let profile: any SiteProfile = RedditSiteProfile() var body: some Scene { WindowGroup { diff --git a/scripts/swiftlint.sh b/scripts/swiftlint.sh new file mode 100755 index 0000000..9c5fbc6 --- /dev/null +++ b/scripts/swiftlint.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH" + +MODE="${1:-build}" +if [[ $# -gt 0 ]]; then + shift +fi + +if [[ $# -eq 0 ]]; then + set -- solipsistweets orion +fi + +if ! command -v swiftlint >/dev/null 2>&1; then + message="SwiftLint is not installed; install it with 'brew install swiftlint'." + if [[ "$MODE" == "build" && -z "${CI:-}" ]]; then + echo "warning: $message" >&2 + exit 0 + fi + if [[ "${CI:-}" == "true" || "${CI:-}" == "1" ]]; then + echo "error: $message" >&2 + exit 1 + fi + echo "error: $message" >&2 + exit 127 +fi + +case "$MODE" in + build) + swiftlint lint \ + --config "$ROOT_DIR/.swiftlint-build.yml" \ + --working-directory "$ROOT_DIR" \ + --strict \ + --quiet \ + --reporter xcode \ + "$@" + ;; + lint) + swiftlint lint \ + --config "$ROOT_DIR/.swiftlint.yml" \ + --working-directory "$ROOT_DIR" \ + --strict \ + --quiet \ + --reporter xcode \ + "$@" + ;; + fix|autofix) + swiftlint lint \ + --fix \ + --format \ + --config "$ROOT_DIR/.swiftlint-autofix.yml" \ + --working-directory "$ROOT_DIR" \ + --quiet \ + "$@" + ;; + *) + echo "usage: $0 [build|lint|fix] [paths...]" >&2 + exit 64 + ;; +esac diff --git a/scripts/time-build.sh b/scripts/time-build.sh new file mode 100755 index 0000000..46d9b07 --- /dev/null +++ b/scripts/time-build.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +project="${PROJECT:-$repo_root/solipsistweets.xcodeproj}" +scheme="${SCHEME:-Verify Full}" +configuration="${CONFIGURATION:-Debug}" +destination="${DESTINATION:-generic/platform=iOS Simulator}" +timing_derived_data="${TIMING_DERIVED_DATA:-$repo_root/.context/build-timing/$scheme}" +build_action="${BUILD_ACTION:-build}" + +args=( + -disableAutomaticPackageResolution + -project "$project" + -scheme "$scheme" + -configuration "$configuration" + -destination "$destination" + -derivedDataPath "$timing_derived_data" + COMPILER_INDEX_STORE_ENABLE="${COMPILER_INDEX_STORE_ENABLE:-NO}" + ENABLE_DEBUG_DYLIB="${ENABLE_DEBUG_DYLIB:-NO}" + ENABLE_TESTABILITY="${ENABLE_TESTABILITY:-NO}" + ENABLE_PREVIEWS="${ENABLE_PREVIEWS:-NO}" + STRING_CATALOG_GENERATE_SYMBOLS="${STRING_CATALOG_GENERATE_SYMBOLS:-NO}" + SWIFT_EMIT_LOC_STRINGS="${SWIFT_EMIT_LOC_STRINGS:-NO}" +) + +if [[ "$destination" == *Simulator* ]]; then + args+=( + ARCHS="${BUILD_ARCHS:-$(uname -m)}" + CODE_SIGNING_ALLOWED="${CODE_SIGNING_ALLOWED:-NO}" + CODE_SIGN_STYLE="${CODE_SIGN_STYLE:-Manual}" + ) +fi + +actions=() +if [[ "${CLEAN:-1}" != "0" ]]; then + actions+=(clean) +fi +actions+=("$build_action") + +xcodebuild "${args[@]}" \ + "${actions[@]}" \ + -showBuildTimingSummary diff --git a/scripts/verify-fast.sh b/scripts/verify-fast.sh new file mode 100755 index 0000000..dd3fdbb --- /dev/null +++ b/scripts/verify-fast.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +project="${PROJECT:-$repo_root/solipsistweets.xcodeproj}" +scheme="${SCHEME:-solipsistweets}" +configuration="${CONFIGURATION:-Debug}" +destination="${DESTINATION:-generic/platform=iOS Simulator}" +derived_data_path="${DERIVED_DATA_PATH:-$repo_root/DerivedData}" + +args=( + -disableAutomaticPackageResolution + -project "$project" + -scheme "$scheme" + -configuration "$configuration" + -destination "$destination" + -derivedDataPath "$derived_data_path" + COMPILER_INDEX_STORE_ENABLE="${COMPILER_INDEX_STORE_ENABLE:-NO}" + ENABLE_DEBUG_DYLIB="${ENABLE_DEBUG_DYLIB:-NO}" + ENABLE_TESTABILITY="${ENABLE_TESTABILITY:-NO}" + ENABLE_PREVIEWS="${ENABLE_PREVIEWS:-NO}" + STRING_CATALOG_GENERATE_SYMBOLS="${STRING_CATALOG_GENERATE_SYMBOLS:-NO}" + SWIFT_EMIT_LOC_STRINGS="${SWIFT_EMIT_LOC_STRINGS:-NO}" +) + +if [[ "$destination" == *Simulator* ]]; then + args+=( + ARCHS="${BUILD_ARCHS:-$(uname -m)}" + CODE_SIGNING_ALLOWED="${CODE_SIGNING_ALLOWED:-NO}" + CODE_SIGN_STYLE="${CODE_SIGN_STYLE:-Manual}" + ) +fi + +xcodebuild "${args[@]}" \ + build-for-testing diff --git a/scripts/verify-full.sh b/scripts/verify-full.sh new file mode 100755 index 0000000..b779664 --- /dev/null +++ b/scripts/verify-full.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +project="${PROJECT:-$repo_root/solipsistweets.xcodeproj}" +scheme="${SCHEME:-Verify Full}" +configuration="${CONFIGURATION:-Debug}" +destination="${DESTINATION:-generic/platform=iOS Simulator}" +derived_data_path="${DERIVED_DATA_PATH:-$repo_root/DerivedData}" + +args=( + -disableAutomaticPackageResolution + -project "$project" + -scheme "$scheme" + -configuration "$configuration" + -destination "$destination" + -derivedDataPath "$derived_data_path" + COMPILER_INDEX_STORE_ENABLE="${COMPILER_INDEX_STORE_ENABLE:-NO}" + ENABLE_DEBUG_DYLIB="${ENABLE_DEBUG_DYLIB:-NO}" + ENABLE_TESTABILITY="${ENABLE_TESTABILITY:-NO}" + ENABLE_PREVIEWS="${ENABLE_PREVIEWS:-NO}" + STRING_CATALOG_GENERATE_SYMBOLS="${STRING_CATALOG_GENERATE_SYMBOLS:-NO}" + SWIFT_EMIT_LOC_STRINGS="${SWIFT_EMIT_LOC_STRINGS:-NO}" +) + +if [[ "$destination" == *Simulator* ]]; then + args+=( + ARCHS="${BUILD_ARCHS:-$(uname -m)}" + CODE_SIGNING_ALLOWED="${CODE_SIGNING_ALLOWED:-NO}" + CODE_SIGN_STYLE="${CODE_SIGN_STYLE:-Manual}" + ) +fi + +xcodebuild "${args[@]}" \ + build diff --git a/solipsistweets.xcodeproj/project.pbxproj b/solipsistweets.xcodeproj/project.pbxproj index 718b7d1..da6600c 100644 --- a/solipsistweets.xcodeproj/project.pbxproj +++ b/solipsistweets.xcodeproj/project.pbxproj @@ -90,6 +90,7 @@ isa = PBXNativeTarget; buildConfigurationList = 160494112E6507440073407B /* Build configuration list for PBXNativeTarget "solipsistweets" */; buildPhases = ( + 160494142E6507440073407B /* SwiftLint */, 160494022E6507430073407B /* Sources */, 160494032E6507430073407B /* Frameworks */, 160494042E6507430073407B /* Resources */, @@ -112,6 +113,7 @@ isa = PBXNativeTarget; buildConfigurationList = 16EE1E3F2E7E546800B03BC8 /* Build configuration list for PBXNativeTarget "orion" */; buildPhases = ( + 16EE1E422E7E546800B03BC8 /* SwiftLint */, 16EE1E332E7E546700B03BC8 /* Sources */, 16EE1E342E7E546700B03BC8 /* Frameworks */, 16EE1E352E7E546700B03BC8 /* Resources */, @@ -185,6 +187,56 @@ }; /* End PBXResourcesBuildPhase section */ +/* Begin PBXShellScriptBuildPhase section */ + 160494142E6507440073407B /* SwiftLint */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "$(SRCROOT)/.swiftlint.yml", + "$(SRCROOT)/.swiftlint-build.yml", + "$(SRCROOT)/scripts/swiftlint.sh", + "$(SRCROOT)/solipsistweets", + ); + name = SwiftLint; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/SwiftLint-solipsistweets.stamp", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"$SRCROOT/scripts/swiftlint.sh\" build \"$SRCROOT/solipsistweets\"\ntouch \"$SCRIPT_OUTPUT_FILE_0\"\n"; + }; + 16EE1E422E7E546800B03BC8 /* SwiftLint */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "$(SRCROOT)/.swiftlint.yml", + "$(SRCROOT)/.swiftlint-build.yml", + "$(SRCROOT)/scripts/swiftlint.sh", + "$(SRCROOT)/solipsistweets", + "$(SRCROOT)/orion", + ); + name = SwiftLint; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/SwiftLint-orion.stamp", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"$SRCROOT/scripts/swiftlint.sh\" build \"$SRCROOT/orion\" \"$SRCROOT/solipsistweets\"\ntouch \"$SCRIPT_OUTPUT_FILE_0\"\n"; + }; +/* End PBXShellScriptBuildPhase section */ + /* Begin PBXSourcesBuildPhase section */ 160494022E6507430073407B /* Sources */ = { isa = PBXSourcesBuildPhase; @@ -207,7 +259,7 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = NO; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; @@ -242,7 +294,7 @@ DEVELOPMENT_TEAM = NFZL2NT288; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; - ENABLE_USER_SCRIPT_SANDBOXING = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu17; GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; @@ -271,7 +323,7 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = NO; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; @@ -306,7 +358,7 @@ DEVELOPMENT_TEAM = NFZL2NT288; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_USER_SCRIPT_SANDBOXING = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu17; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; @@ -330,6 +382,9 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 5; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = NO; + GCC_TREAT_WARNINGS_AS_ERRORS = YES; + RUN_CLANG_STATIC_ANALYZER = YES; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = NFZL2NT288; ENABLE_APP_SANDBOX = YES; @@ -342,8 +397,6 @@ INFOPLIST_KEY_CFBundleURLTypes = "{\n CFBundleTypeRole = Editor;\n CFBundleURLName = echodotapp;\n CFBundleURLSchemes = (\n echodotapp\n );\n}"; "INFOPLIST_KEY_CFBundleURLTypes[sdk=iphoneos*]" = "{\n CFBundleTypeRole = Editor;\n CFBundleURLName = echodotapp;\n CFBundleURLSchemes = (\n echodotapp\n );\n}"; "INFOPLIST_KEY_CFBundleURLTypes[sdk=iphonesimulator*]" = "{\n CFBundleTypeRole = Editor;\n CFBundleURLName = echodotapp;\n CFBundleURLSchemes = (\n echodotapp\n );\n}"; - "INFOPLIST_KEY_CFBundleURLTypes[sdk=macosx*]" = "{\n CFBundleTypeRole = Editor;\n CFBundleURLName = echodotapp;\n CFBundleURLSchemes = (\n echodotapp\n );\n}"; - "INFOPLIST_KEY_CFBundleURLTypes[sdk=xros*]" = "{\n CFBundleTypeRole = Editor;\n CFBundleURLName = echodotapp;\n CFBundleURLSchemes = (\n echodotapp\n );\n}"; INFOPLIST_KEY_NSCameraUsageDescription = "Allow camera access for web pages when you choose to capture photos or video."; INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Allow location access for web pages when you choose to share your location."; INFOPLIST_KEY_NSMicrophoneUsageDescription = "Allow microphone access for web pages when you choose to record audio or video."; @@ -360,22 +413,23 @@ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; IPHONEOS_DEPLOYMENT_TARGET = 17.6; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; - "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; - MACOSX_DEPLOYMENT_TARGET = 26.0; MARKETING_VERSION = 1.04; PRODUCT_BUNDLE_IDENTIFIER = me.whydontyoulove.solipsistweets; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; - SDKROOT = auto; + SDKROOT = iphoneos; STRING_CATALOG_GENERATE_SYMBOLS = YES; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + OTHER_SWIFT_FLAGS = "$(inherited) -strict-memory-safety"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_TREAT_WARNINGS_AS_ERRORS = YES; + SWIFT_UPCOMING_FEATURE_EXISTENTIAL_ANY = YES; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2,7"; - XROS_DEPLOYMENT_TARGET = 26.0; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; }; @@ -386,6 +440,9 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 5; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = NO; + GCC_TREAT_WARNINGS_AS_ERRORS = YES; + RUN_CLANG_STATIC_ANALYZER = YES; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = NFZL2NT288; ENABLE_APP_SANDBOX = YES; @@ -398,8 +455,6 @@ INFOPLIST_KEY_CFBundleURLTypes = "{\n CFBundleTypeRole = Editor;\n CFBundleURLName = echodotapp;\n CFBundleURLSchemes = (\n echodotapp\n );\n}"; "INFOPLIST_KEY_CFBundleURLTypes[sdk=iphoneos*]" = "{\n CFBundleTypeRole = Editor;\n CFBundleURLName = echodotapp;\n CFBundleURLSchemes = (\n echodotapp\n );\n}"; "INFOPLIST_KEY_CFBundleURLTypes[sdk=iphonesimulator*]" = "{\n CFBundleTypeRole = Editor;\n CFBundleURLName = echodotapp;\n CFBundleURLSchemes = (\n echodotapp\n );\n}"; - "INFOPLIST_KEY_CFBundleURLTypes[sdk=macosx*]" = "{\n CFBundleTypeRole = Editor;\n CFBundleURLName = echodotapp;\n CFBundleURLSchemes = (\n echodotapp\n );\n}"; - "INFOPLIST_KEY_CFBundleURLTypes[sdk=xros*]" = "{\n CFBundleTypeRole = Editor;\n CFBundleURLName = echodotapp;\n CFBundleURLSchemes = (\n echodotapp\n );\n}"; INFOPLIST_KEY_NSCameraUsageDescription = "Allow camera access for web pages when you choose to capture photos or video."; INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Allow location access for web pages when you choose to share your location."; INFOPLIST_KEY_NSMicrophoneUsageDescription = "Allow microphone access for web pages when you choose to record audio or video."; @@ -416,22 +471,23 @@ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; IPHONEOS_DEPLOYMENT_TARGET = 17.6; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; - "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; - MACOSX_DEPLOYMENT_TARGET = 26.0; MARKETING_VERSION = 1.04; PRODUCT_BUNDLE_IDENTIFIER = me.whydontyoulove.solipsistweets; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; - SDKROOT = auto; + SDKROOT = iphoneos; STRING_CATALOG_GENERATE_SYMBOLS = YES; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + OTHER_SWIFT_FLAGS = "$(inherited) -strict-memory-safety"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_TREAT_WARNINGS_AS_ERRORS = YES; + SWIFT_UPCOMING_FEATURE_EXISTENTIAL_ANY = YES; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2,7"; - XROS_DEPLOYMENT_TARGET = 26.0; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; }; @@ -442,6 +498,9 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = NO; + GCC_TREAT_WARNINGS_AS_ERRORS = YES; + RUN_CLANG_STATIC_ANALYZER = YES; DEVELOPMENT_TEAM = NFZL2NT288; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -464,12 +523,16 @@ PRODUCT_BUNDLE_IDENTIFIER = me.whydontyoulove.orion; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; + OTHER_SWIFT_FLAGS = "$(inherited) -strict-memory-safety"; STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_TREAT_WARNINGS_AS_ERRORS = YES; + SWIFT_UPCOMING_FEATURE_EXISTENTIAL_ANY = YES; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; @@ -481,6 +544,9 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = NO; + GCC_TREAT_WARNINGS_AS_ERRORS = YES; + RUN_CLANG_STATIC_ANALYZER = YES; DEVELOPMENT_TEAM = NFZL2NT288; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -503,12 +569,16 @@ PRODUCT_BUNDLE_IDENTIFIER = me.whydontyoulove.orion; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; + OTHER_SWIFT_FLAGS = "$(inherited) -strict-memory-safety"; STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_TREAT_WARNINGS_AS_ERRORS = YES; + SWIFT_UPCOMING_FEATURE_EXISTENTIAL_ANY = YES; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; VALIDATE_PRODUCT = YES; }; diff --git a/solipsistweets.xcodeproj/xcshareddata/xcschemes/Verify Full.xcscheme b/solipsistweets.xcodeproj/xcshareddata/xcschemes/Verify Full.xcscheme new file mode 100644 index 0000000..b56a8ec --- /dev/null +++ b/solipsistweets.xcodeproj/xcshareddata/xcschemes/Verify Full.xcscheme @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/solipsistweets/ContentView.swift b/solipsistweets/ContentView.swift index f785898..d0b8825 100644 --- a/solipsistweets/ContentView.swift +++ b/solipsistweets/ContentView.swift @@ -11,17 +11,17 @@ import Combine struct ContentView: View { private let screenTimeBadgeThreshold: TimeInterval = 20 * 60 - private static let testFlightURL = URL(string: "https://testflight.apple.com/join/N3DtJcgD")! + private static let testFlightURL = URL.required(string: "https://testflight.apple.com/join/N3DtJcgD") @Binding var requestedURL: URL @State private var isLoading: Bool = true - @State private var lastErrorDescription: String? = nil + @State private var lastErrorDescription: String? @StateObject private var shakeDetector = ShakeDetector() @State private var showShareBanner = false @State private var isShareSheetPresented = false @State private var hideShareBannerTask: Task? @EnvironmentObject private var screenTimeTracker: OnScreenTimeTracker @Environment(\.colorScheme) private var colorScheme - let profile: SiteProfile + let profile: any SiteProfile var body: some View { ZStack { @@ -152,15 +152,21 @@ private func formatDuration(_ seconds: TimeInterval) -> String { let hours = totalSeconds / 3600 let minutes = (totalSeconds % 3600) / 60 let secs = totalSeconds % 60 + let paddedMinutes = twoDigitString(minutes) + let paddedSeconds = twoDigitString(secs) if hours > 0 { - return String(format: "%d:%02d:%02d", hours, minutes, secs) + return "\(hours):\(paddedMinutes):\(paddedSeconds)" } else { - return String(format: "%d:%02d", minutes, secs) + return "\(minutes):\(paddedSeconds)" } } +private func twoDigitString(_ value: Int) -> String { + value < 10 ? "0\(value)" : "\(value)" +} + #Preview { - ContentView(requestedURL: .constant(URL(string: "https://x.com/notifications")!), profile: XSiteProfile()) + ContentView(requestedURL: .constant(URL.required(string: "https://x.com/notifications")), profile: XSiteProfile()) .environmentObject(OnScreenTimeTracker()) } @@ -171,7 +177,10 @@ private struct ShareSheet: UIViewControllerRepresentable { UIActivityViewController(activityItems: activityItems, applicationActivities: nil) } - func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {} + func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) { + _ = uiViewController + _ = context + } } final class ShakeDetector: ObservableObject { @@ -205,7 +214,9 @@ final class ShakeDetector: ObservableObject { } deinit { - motionManager.stopDeviceMotionUpdates() + MainActor.assumeIsolated { + motionManager.stopDeviceMotionUpdates() + } } } @@ -216,7 +227,7 @@ struct WebView: UIViewRepresentable { let url: URL @Binding var isLoading: Bool @Binding var lastErrorDescription: String? - let profile: SiteProfile + let profile: any SiteProfile typealias UIViewType = WKWebView @@ -261,29 +272,33 @@ final class Coordinator: NSObject, WKNavigationDelegate, WKUIDelegate { private static let safeInlineSchemes: Set = ["about", "data"] private var parent: WebView - private let profile: SiteProfile + private let profile: any SiteProfile private var didInstallContentRules: Bool = false private var lastProgrammaticRequestURL: URL? - init(parent: WebView, profile: SiteProfile) { + init(parent: WebView, profile: any SiteProfile) { self.parent = parent self.profile = profile } + // swiftlint:disable:next implicitly_unwrapped_optional func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { DispatchQueue.main.async { [weak self] in self?.parent.isLoading = true } } - func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { + // swiftlint:disable:next implicitly_unwrapped_optional + func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: any Error) { handleNavigationFailure(error, prefix: "Navigation failed") } - func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { + // swiftlint:disable:next implicitly_unwrapped_optional + func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: any Error) { handleNavigationFailure(error, prefix: "Provisional navigation failed") } + // swiftlint:disable:next implicitly_unwrapped_optional func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { #if DEBUG print("Navigation finished: \(webView.url?.absoluteString ?? "")") @@ -299,7 +314,7 @@ final class Coordinator: NSObject, WKNavigationDelegate, WKUIDelegate { } } - func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { + func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping @MainActor @Sendable (WKNavigationActionPolicy) -> Void) { guard let url = navigationAction.request.url, let scheme = url.scheme?.lowercased() else { decisionHandler(.allow) return @@ -382,7 +397,7 @@ private extension Coordinator { lastProgrammaticRequestURL = url } - func handleNavigationFailure(_ error: Error, prefix: String) { + func handleNavigationFailure(_ error: any Error, prefix: String) { if Self.shouldIgnoreNavigationError(error) { #if DEBUG print("Ignoring expected navigation cancellation: \(error.localizedDescription)") @@ -398,7 +413,7 @@ private extension Coordinator { } } - func handleHTTPNavigation(_ url: URL, action: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { + func handleHTTPNavigation(_ url: URL, action: WKNavigationAction, decisionHandler: @MainActor @Sendable (WKNavigationActionPolicy) -> Void) { let isUserTap = action.navigationType == .linkActivated let isMainFrame = action.targetFrame?.isMainFrame ?? true @@ -434,7 +449,7 @@ private extension Coordinator { return String(lowercased.dropLast()) } - static func shouldIgnoreNavigationError(_ error: Error) -> Bool { + static func shouldIgnoreNavigationError(_ error: any Error) -> Bool { let nsError = error as NSError if nsError.domain == NSURLErrorDomain && nsError.code == NSURLErrorCancelled { return true @@ -507,7 +522,7 @@ extension Coordinator { if host == "user", let screenName = valueFor("screen_name"), !screenName.isEmpty { return URL(string: "https://x.com/\(screenName)") } - if (host == "status" || host == "tweet"), let id = valueFor("id"), !id.isEmpty { + if host == "status" || host == "tweet", let id = valueFor("id"), !id.isEmpty { return URL(string: "https://x.com/i/web/status/\(id)") } if host == "messages" || path == "/messages" { diff --git a/solipsistweets/OnScreenTimeTracker.swift b/solipsistweets/OnScreenTimeTracker.swift index 3f225f7..669098b 100644 --- a/solipsistweets/OnScreenTimeTracker.swift +++ b/solipsistweets/OnScreenTimeTracker.swift @@ -1,6 +1,7 @@ import Foundation import Combine +@MainActor final class OnScreenTimeTracker: ObservableObject { private static let keyPrefix = "onScreenSeconds_" @@ -17,7 +18,9 @@ final class OnScreenTimeTracker: ObservableObject { } deinit { - timer?.invalidate() + MainActor.assumeIsolated { + timer?.invalidate() + } } func start() { @@ -26,7 +29,9 @@ final class OnScreenTimeTracker: ObservableObject { secondsToday = userDefaults.double(forKey: Self.key(for: Date())) lastTickDate = Date() let timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in - self?.tick() + Task { @MainActor [weak self] in + self?.tick() + } } RunLoop.main.add(timer, forMode: .common) self.timer = timer diff --git a/solipsistweets/SiteProfile.swift b/solipsistweets/SiteProfile.swift index f12b260..a937900 100644 --- a/solipsistweets/SiteProfile.swift +++ b/solipsistweets/SiteProfile.swift @@ -2,6 +2,15 @@ import Foundation import WebKit import UIKit +extension URL { + static func required(string: String) -> URL { + guard let url = URL(string: string) else { + fatalError("Invalid required URL: \(string)") + } + return url + } +} + private enum SharedUserAgent { static var mobileSafariCurrentDevice: String { let osVersion = UIDevice.current.systemVersion @@ -47,7 +56,7 @@ protocol SiteProfile { struct XSiteProfile: SiteProfile { let canonicalHosts: Set = ["x.com", "www.x.com", "mobile.x.com", "twitter.com", "www.twitter.com"] - var startURL: URL { URL(string: "https://x.com/notifications")! } + var startURL: URL { URL.required(string: "https://x.com/notifications") } var userAgent: String { SharedUserAgent.mobileSafariCurrentDevice } func mapDeepLinkToHTTPS(_ url: URL) -> URL? { Coordinator.mapTwitterDeepLinkToHTTPS(url: url) @@ -59,7 +68,7 @@ struct XSiteProfile: SiteProfile { struct RedditSiteProfile: SiteProfile { let canonicalHosts: Set = ["reddit.com", "www.reddit.com", "old.reddit.com", "m.reddit.com"] - var startURL: URL { URL(string: "https://www.reddit.com/")! } + var startURL: URL { URL.required(string: "https://www.reddit.com/") } var userAgent: String { SharedUserAgent.mobileSafariCurrentDevice } func mapDeepLinkToHTTPS(_ url: URL) -> URL? { // No known reddit:// scheme mapping needed; return nil to cancel deep links diff --git a/solipsistweets/solipsistweetsApp.swift b/solipsistweets/solipsistweetsApp.swift index 3cb3de1..d23d2aa 100644 --- a/solipsistweets/solipsistweetsApp.swift +++ b/solipsistweets/solipsistweetsApp.swift @@ -6,11 +6,11 @@ import SwiftUI @main -struct solipsistweetsApp: App { +struct SolipsistweetsApp: App { @Environment(\.scenePhase) private var scenePhase @State private var requestedURL: URL = XSiteProfile().startURL @StateObject private var screenTimeTracker = OnScreenTimeTracker() - private let profile: SiteProfile = XSiteProfile() + private let profile: any SiteProfile = XSiteProfile() var body: some Scene { WindowGroup { From a4a5f13562c09e6ee08ae0bf414670e5dc524ea9 Mon Sep 17 00:00:00 2001 From: Chris Beiser Date: Sun, 26 Apr 2026 16:41:35 -1000 Subject: [PATCH 2/4] Add Bluesky Support --- solipsistweets/ContentView.swift | 112 +++++++++-- solipsistweets/SiteProfile.swift | 46 +++++ solipsistweets/solipsistweetsApp.swift | 261 ++++++++++++++++++++++++- 3 files changed, 401 insertions(+), 18 deletions(-) diff --git a/solipsistweets/ContentView.swift b/solipsistweets/ContentView.swift index d0b8825..0541e2e 100644 --- a/solipsistweets/ContentView.swift +++ b/solipsistweets/ContentView.swift @@ -19,9 +19,15 @@ struct ContentView: View { @State private var showShareBanner = false @State private var isShareSheetPresented = false @State private var hideShareBannerTask: Task? + @State private var showRemoveCurrentTabConfirmation = false @EnvironmentObject private var screenTimeTracker: OnScreenTimeTracker @Environment(\.colorScheme) private var colorScheme let profile: any SiteProfile + var switcherIcon: String? = nil + var setupTabs: [SocialTab] = [] + var onSwitchTab: (() -> Void)? = nil + var onRemoveActiveTab: (() -> Void)? = nil + var onSetupTab: ((SocialTab) -> Void)? = nil var body: some View { ZStack { @@ -67,27 +73,65 @@ struct ContentView: View { } .overlay(alignment: .top) { if showShareBanner { - Button { - isShareSheetPresented = true - dismissShareBanner() - } label: { - HStack(spacing: 8) { - Image(systemName: "square.and.arrow.up") - .font(.subheadline.weight(.semibold)) - Text("Share TestFlight") - .font(.subheadline.weight(.semibold)) + VStack(spacing: 8) { + Button { + isShareSheetPresented = true + dismissShareBanner() + } label: { + HStack(spacing: 8) { + Image(systemName: "square.and.arrow.up") + .font(.subheadline.weight(.semibold)) + Text("Share TestFlight") + .font(.subheadline.weight(.semibold)) + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + .foregroundStyle(.primary) + .background(.regularMaterial, in: Capsule()) + } + .buttonStyle(.plain) + + ForEach(setupTabs) { tab in + Button { + onSetupTab?(tab) + dismissShareBanner() + } label: { + HStack(spacing: 8) { + Text(tab.emoji) + Text(tab.setupTitle) + .font(.subheadline.weight(.semibold)) + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + .foregroundStyle(.primary) + .background(.regularMaterial, in: Capsule()) + } + .buttonStyle(.plain) } - .padding(.horizontal, 14) - .padding(.vertical, 10) - .foregroundStyle(.primary) - .background(.regularMaterial, in: Capsule()) } - .buttonStyle(.plain) .padding(.top, 8) .padding(.horizontal, 12) .transition(.move(edge: .top).combined(with: .opacity)) } } + .overlay(alignment: .topTrailing) { + if let switcherIcon { + Text(switcherIcon) + .font(.title3) + .frame(width: 44, height: 44) + .liquidGlass(in: Circle()) + .padding(.top, 4) + .padding(.trailing, 50) + .onTapGesture { + onSwitchTab?() + } + .onLongPressGesture(minimumDuration: 0.7) { + showRemoveCurrentTabConfirmation = true + } + .accessibilityLabel("Switch account") + .accessibilityAddTraits(.isButton) + } + } .onAppear { shakeDetector.start() } @@ -101,6 +145,12 @@ struct ContentView: View { .sheet(isPresented: $isShareSheetPresented) { ShareSheet(activityItems: [Self.testFlightURL]) } + .confirmationDialog("Remove this tab?", isPresented: $showRemoveCurrentTabConfirmation, titleVisibility: .visible) { + Button("Remove Tab", role: .destructive) { + onRemoveActiveTab?() + } + Button("Cancel", role: .cancel) {} + } } private var shouldShowScreenTimeBadge: Bool { @@ -145,6 +195,18 @@ struct ContentView: View { } } + +private extension View { + @ViewBuilder + func liquidGlass(in shape: S) -> some View { + if #available(iOS 26.0, *) { + self.glassEffect(.regular, in: shape) + } else { + self.background(.regularMaterial, in: shape) + } + } +} + // MARK: - Duration formatting private func formatDuration(_ seconds: TimeInterval) -> String { @@ -608,6 +670,28 @@ enum ContentBlocker { } } + // Keep Bluesky rules separate from the original X/Twitter rules. + static let blueskyRulesJSON = """ + [ + { + "trigger": { "url-filter": ".*", "if-domain": ["cope.works", "www.cope.works"] }, + "action": { "type": "css-display-none", "selector": "[data-testid='followingFeedPage']" } + }, + { + "trigger": { "url-filter": ".*", "if-domain": ["cope.works", "www.cope.works"] }, + "action": { "type": "css-display-none", "selector": "[aria-label='Home']" } + }, + { + "trigger": { "url-filter": ".*", "if-domain": ["cope.works", "www.cope.works"] }, + "action": { "type": "css-display-none", "selector": "[aria-label='Lists']" } + }, + { + "trigger": { "url-filter": ".*", "if-domain": ["cope.works", "www.cope.works"] }, + "action": { "type": "css-display-none", "selector": "[aria-label='Feeds']" } + } + ] + """ + // Keep the original X/Twitter rules available for XSiteProfile static let defaultRulesJSON = """ [ diff --git a/solipsistweets/SiteProfile.swift b/solipsistweets/SiteProfile.swift index a937900..b44504f 100644 --- a/solipsistweets/SiteProfile.swift +++ b/solipsistweets/SiteProfile.swift @@ -38,6 +38,42 @@ private enum SharedUserAgent { } } + +enum SocialTab: String, CaseIterable, Identifiable { + case x + case bluesky + + var id: String { rawValue } + + var displayName: String { + switch self { + case .x: return "Twitter / X" + case .bluesky: return "Bluesky" + } + } + + var setupTitle: String { + switch self { + case .x: return "Set Up X" + case .bluesky: return "Set Up Bluesky" + } + } + + var emoji: String { + switch self { + case .x: return "🐦" + case .bluesky: return "🦋" + } + } + + var profile: any SiteProfile { + switch self { + case .x: return XSiteProfile() + case .bluesky: return BlueskySiteProfile() + } + } +} + protocol SiteProfile { // Hosts that are considered “internal” and should open in-app var canonicalHosts: Set { get } @@ -66,6 +102,16 @@ struct XSiteProfile: SiteProfile { var contentBlockerRulesJSON: String { ContentBlocker.defaultRulesJSON } } +struct BlueskySiteProfile: SiteProfile { + let canonicalHosts: Set = ["cope.works", "www.cope.works"] + var startURL: URL { URL.required(string: "https://cope.works/notifications") } + var userAgent: String { SharedUserAgent.mobileSafariCurrentDevice } + func mapDeepLinkToHTTPS(_ url: URL) -> URL? { nil } + func mapEchoDotAppToHTTPS(_ url: URL) -> URL? { nil } + var contentBlockerIdentifier: String { "com.solipsistweets.ContentBlocker.bluesky.rules.v4" } + var contentBlockerRulesJSON: String { ContentBlocker.blueskyRulesJSON } +} + struct RedditSiteProfile: SiteProfile { let canonicalHosts: Set = ["reddit.com", "www.reddit.com", "old.reddit.com", "m.reddit.com"] var startURL: URL { URL.required(string: "https://www.reddit.com/") } diff --git a/solipsistweets/solipsistweetsApp.swift b/solipsistweets/solipsistweetsApp.swift index d23d2aa..646c15a 100644 --- a/solipsistweets/solipsistweetsApp.swift +++ b/solipsistweets/solipsistweetsApp.swift @@ -4,21 +4,148 @@ // import SwiftUI +import Combine + +@MainActor +final class SocialAccountStore: ObservableObject { + private static let configuredTabsKey = "configuredSocialTabs.v1" + private static let activeTabKey = "activeSocialTab.v1" + private static let didHandleBlueskyUpgradeKey = "didHandleBlueskyUpgradePrompt.v1" + + @Published private(set) var configuredTabs: [SocialTab] + @Published var activeTab: SocialTab + @Published var isPresentingInitialChoice: Bool + @Published var isPresentingUpgradePrompt: Bool + + private let userDefaults: UserDefaults + + init(userDefaults: UserDefaults = .standard) { + self.userDefaults = userDefaults + let rawConfiguredTabs = userDefaults.stringArray(forKey: Self.configuredTabsKey) ?? [] + var configuredTabs = rawConfiguredTabs.compactMap(SocialTab.init(rawValue:)) + let isExistingInstall = configuredTabs.isEmpty && userDefaults.dictionaryRepresentation().keys.contains { $0.hasPrefix("onScreenSeconds_") } + if isExistingInstall { + configuredTabs = [.x] + userDefaults.set(configuredTabs.map(\.rawValue), forKey: Self.configuredTabsKey) + } + let activeTab = SocialTab(rawValue: userDefaults.string(forKey: Self.activeTabKey) ?? "") ?? configuredTabs.first ?? .x + + self.configuredTabs = configuredTabs + self.activeTab = activeTab + self.isPresentingInitialChoice = configuredTabs.isEmpty + self.isPresentingUpgradePrompt = false + + guard !configuredTabs.isEmpty else { return } + if !configuredTabs.contains(activeTab), let fallback = configuredTabs.first { + self.activeTab = fallback + userDefaults.set(fallback.rawValue, forKey: Self.activeTabKey) + } + if !configuredTabs.contains(.bluesky), !userDefaults.bool(forKey: Self.didHandleBlueskyUpgradeKey) { + self.isPresentingUpgradePrompt = true + } + } + + var hasMultipleTabs: Bool { + configuredTabs.count > 1 + } + + var nextTab: SocialTab? { + guard let currentIndex = configuredTabs.firstIndex(of: activeTab), configuredTabs.count > 1 else { return nil } + let nextIndex = configuredTabs.index(after: currentIndex) + return configuredTabs[nextIndex == configuredTabs.endIndex ? configuredTabs.startIndex : nextIndex] + } + + var missingTabs: [SocialTab] { + SocialTab.allCases.filter { !configuredTabs.contains($0) } + } + + var activeProfile: any SiteProfile { + activeTab.profile + } + + func completeInitialChoice(_ tabs: [SocialTab]) { + let selectedTabs = normalized(tabs) + configuredTabs = selectedTabs + activeTab = selectedTabs.first ?? .x + isPresentingInitialChoice = false + persistTabs() + userDefaults.set(activeTab.rawValue, forKey: Self.activeTabKey) + userDefaults.set(configuredTabs.contains(.bluesky), forKey: Self.didHandleBlueskyUpgradeKey) + } + + func add(_ tab: SocialTab) { + guard !configuredTabs.contains(tab) else { return } + configuredTabs = normalized(configuredTabs + [tab]) + activeTab = tab + isPresentingInitialChoice = false + persistTabs() + userDefaults.set(activeTab.rawValue, forKey: Self.activeTabKey) + if tab == .bluesky { + userDefaults.set(true, forKey: Self.didHandleBlueskyUpgradeKey) + isPresentingUpgradePrompt = false + } + } + + func remove(_ tab: SocialTab) { + guard configuredTabs.count > 1 else { return } + configuredTabs.removeAll { $0 == tab } + if activeTab == tab, let fallback = configuredTabs.first { + activeTab = fallback + userDefaults.set(fallback.rawValue, forKey: Self.activeTabKey) + } + persistTabs() + } + + func switchToNextTab() { + guard let currentIndex = configuredTabs.firstIndex(of: activeTab), configuredTabs.count > 1 else { return } + let nextIndex = configuredTabs.index(after: currentIndex) + activeTab = configuredTabs[nextIndex == configuredTabs.endIndex ? configuredTabs.startIndex : nextIndex] + userDefaults.set(activeTab.rawValue, forKey: Self.activeTabKey) + } + + func declineBlueskyUpgrade() { + userDefaults.set(true, forKey: Self.didHandleBlueskyUpgradeKey) + isPresentingUpgradePrompt = false + } + + func acceptBlueskyUpgrade() { + add(.bluesky) + userDefaults.set(true, forKey: Self.didHandleBlueskyUpgradeKey) + isPresentingUpgradePrompt = false + } + + private func normalized(_ tabs: [SocialTab]) -> [SocialTab] { + SocialTab.allCases.filter { tabs.contains($0) } + } + + private func persistTabs() { + userDefaults.set(configuredTabs.map(\.rawValue), forKey: Self.configuredTabsKey) + } +} @main struct SolipsistweetsApp: App { @Environment(\.scenePhase) private var scenePhase - @State private var requestedURL: URL = XSiteProfile().startURL + @State private var requestedURL: URL @StateObject private var screenTimeTracker = OnScreenTimeTracker() - private let profile: any SiteProfile = XSiteProfile() + @StateObject private var accountStore: SocialAccountStore + + init() { + let accountStore = SocialAccountStore() + _accountStore = StateObject(wrappedValue: accountStore) + _requestedURL = State(initialValue: accountStore.activeProfile.startURL) + } var body: some Scene { WindowGroup { - ContentView(requestedURL: $requestedURL, profile: profile) + SocialWebContainer(requestedURL: $requestedURL) .environmentObject(screenTimeTracker) + .environmentObject(accountStore) .onOpenURL { url in guard url.scheme?.lowercased() == "echodotapp" else { return } - if let mapped = profile.mapEchoDotAppToHTTPS(url) { + if let mapped = XSiteProfile().mapEchoDotAppToHTTPS(url) { + accountStore.add(.x) + accountStore.activeTab = .x requestedURL = mapped } } @@ -39,3 +166,129 @@ struct SolipsistweetsApp: App { } } } + +private struct SocialWebContainer: View { + @Binding var requestedURL: URL + @EnvironmentObject private var accountStore: SocialAccountStore + + var body: some View { + Group { + if accountStore.isPresentingInitialChoice { + AccountChoiceView(title: "Which accounts would you like to use?", subtitle: "Choose X, Bluesky, or both to get started.") { tabs in + accountStore.completeInitialChoice(tabs) + requestedURL = accountStore.activeProfile.startURL + } + } else if !accountStore.activeProfile.canonicalHosts.contains(requestedURL.host?.lowercased() ?? "") { + Color.clear + .onAppear { + requestedURL = accountStore.activeProfile.startURL + } + } else { + ContentView( + requestedURL: $requestedURL, + profile: accountStore.activeProfile, + switcherIcon: accountStore.nextTab?.emoji, + setupTabs: accountStore.missingTabs, + onSwitchTab: { + accountStore.switchToNextTab() + requestedURL = accountStore.activeProfile.startURL + }, + onRemoveActiveTab: { + accountStore.remove(accountStore.activeTab) + requestedURL = accountStore.activeProfile.startURL + }, + onSetupTab: { tab in + accountStore.add(tab) + requestedURL = accountStore.activeProfile.startURL + } + ) + .id(accountStore.activeTab) + .alert("Bluesky Support Is Here", isPresented: $accountStore.isPresentingUpgradePrompt) { + Button("Set Up Bluesky") { + accountStore.acceptBlueskyUpgrade() + requestedURL = accountStore.activeProfile.startURL + } + Button("Keep Using Twitter / X", role: .cancel) { + accountStore.declineBlueskyUpgrade() + } + } message: { + Text("Echo can now open Bluesky alongside Twitter / X. Add Bluesky now, or keep your current setup and add it later by shaking your device.") + } + } + } + .onChange(of: accountStore.activeTab) { _, _ in + requestedURL = accountStore.activeProfile.startURL + } + } +} + +private struct AccountChoiceView: View { + let title: String + let subtitle: String + let onComplete: ([SocialTab]) -> Void + @State private var selectedTabs: Set = [.x] + + var body: some View { + VStack(spacing: 24) { + Spacer() + + VStack(spacing: 10) { + Text(title) + .font(.title2.weight(.bold)) + .multilineTextAlignment(.center) + Text(subtitle) + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + + VStack(spacing: 12) { + ForEach(SocialTab.allCases) { tab in + Button { + toggle(tab) + } label: { + HStack(spacing: 12) { + Text(tab.emoji) + .font(.title3) + Text(tab.displayName) + .font(.headline) + Spacer() + Image(systemName: selectedTabs.contains(tab) ? "checkmark.circle.fill" : "circle") + .font(.title3) + .foregroundStyle(selectedTabs.contains(tab) ? Color.accentColor : Color.secondary) + } + .padding() + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 18, style: .continuous)) + } + .buttonStyle(.plain) + } + } + + Button { + onComplete(SocialTab.allCases.filter { selectedTabs.contains($0) }) + } label: { + Text("Continue") + .font(.headline) + .frame(maxWidth: .infinity) + .padding() + .background(Color.accentColor, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) + .foregroundStyle(.white) + } + .disabled(selectedTabs.isEmpty) + .opacity(selectedTabs.isEmpty ? 0.5 : 1) + + Spacer() + } + .padding(24) + .background(Color(.systemGroupedBackground)) + } + + private func toggle(_ tab: SocialTab) { + if selectedTabs.contains(tab) { + guard selectedTabs.count > 1 else { return } + selectedTabs.remove(tab) + } else { + selectedTabs.insert(tab) + } + } +} From 58d0b83b6ec3a657c428c7efc1b4da95fef79d7d Mon Sep 17 00:00:00 2001 From: Chris Beiser Date: Sun, 26 Apr 2026 17:13:43 -1000 Subject: [PATCH 3/4] Integrate the fast builds and bluesky together --- solipsistweets.xcodeproj/project.pbxproj | 4 +- .../xcschemes/xcschememanagement.plist | 9 ++- solipsistweets/ContentView.swift | 79 ++++++++++++++++--- solipsistweets/solipsistweetsApp.swift | 10 ++- 4 files changed, 84 insertions(+), 18 deletions(-) diff --git a/solipsistweets.xcodeproj/project.pbxproj b/solipsistweets.xcodeproj/project.pbxproj index da6600c..4874d3f 100644 --- a/solipsistweets.xcodeproj/project.pbxproj +++ b/solipsistweets.xcodeproj/project.pbxproj @@ -209,7 +209,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "\"$SRCROOT/scripts/swiftlint.sh\" build \"$SRCROOT/solipsistweets\"\ntouch \"$SCRIPT_OUTPUT_FILE_0\"\n"; + shellScript = "set -e\n\"$SRCROOT/scripts/swiftlint.sh\" build \"$SRCROOT/solipsistweets\"\ntouch \"$SCRIPT_OUTPUT_FILE_0\"\n"; }; 16EE1E422E7E546800B03BC8 /* SwiftLint */ = { isa = PBXShellScriptBuildPhase; @@ -233,7 +233,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "\"$SRCROOT/scripts/swiftlint.sh\" build \"$SRCROOT/orion\" \"$SRCROOT/solipsistweets\"\ntouch \"$SCRIPT_OUTPUT_FILE_0\"\n"; + shellScript = "set -e\n\"$SRCROOT/scripts/swiftlint.sh\" build \"$SRCROOT/orion\" \"$SRCROOT/solipsistweets\"\ntouch \"$SCRIPT_OUTPUT_FILE_0\"\n"; }; /* End PBXShellScriptBuildPhase section */ diff --git a/solipsistweets.xcodeproj/xcuserdata/chrisbeiser.xcuserdatad/xcschemes/xcschememanagement.plist b/solipsistweets.xcodeproj/xcuserdata/chrisbeiser.xcuserdatad/xcschemes/xcschememanagement.plist index f106088..7b3c62d 100644 --- a/solipsistweets.xcodeproj/xcuserdata/chrisbeiser.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/solipsistweets.xcodeproj/xcuserdata/chrisbeiser.xcuserdatad/xcschemes/xcschememanagement.plist @@ -4,16 +4,21 @@ SchemeUserState - orion.xcscheme_^#shared#^_ + Verify Full.xcscheme_^#shared#^_ orderHint 0 - solipsistweets.xcscheme_^#shared#^_ + orion.xcscheme_^#shared#^_ orderHint 1 + solipsistweets.xcscheme_^#shared#^_ + + orderHint + 2 + diff --git a/solipsistweets/ContentView.swift b/solipsistweets/ContentView.swift index 0541e2e..254357a 100644 --- a/solipsistweets/ContentView.swift +++ b/solipsistweets/ContentView.swift @@ -20,14 +20,36 @@ struct ContentView: View { @State private var isShareSheetPresented = false @State private var hideShareBannerTask: Task? @State private var showRemoveCurrentTabConfirmation = false + @State private var isSwitcherPressed = false @EnvironmentObject private var screenTimeTracker: OnScreenTimeTracker @Environment(\.colorScheme) private var colorScheme let profile: any SiteProfile - var switcherIcon: String? = nil + var switcherIcon: String? + var removableTabs: [SocialTab] = [] var setupTabs: [SocialTab] = [] - var onSwitchTab: (() -> Void)? = nil - var onRemoveActiveTab: (() -> Void)? = nil - var onSetupTab: ((SocialTab) -> Void)? = nil + var onSwitchTab: (() -> Void)? + var onRemoveTab: ((SocialTab) -> Void)? + var onSetupTab: ((SocialTab) -> Void)? + + init( + requestedURL: Binding, + profile: any SiteProfile, + switcherIcon: String? = nil, + removableTabs: [SocialTab] = [], + setupTabs: [SocialTab] = [], + onSwitchTab: (() -> Void)? = nil, + onRemoveTab: ((SocialTab) -> Void)? = nil, + onSetupTab: ((SocialTab) -> Void)? = nil + ) { + _requestedURL = requestedURL + self.profile = profile + self.switcherIcon = switcherIcon + self.removableTabs = removableTabs + self.setupTabs = setupTabs + self.onSwitchTab = onSwitchTab + self.onRemoveTab = onRemoveTab + self.onSetupTab = onSetupTab + } var body: some View { ZStack { @@ -120,14 +142,18 @@ struct ContentView: View { .font(.title3) .frame(width: 44, height: 44) .liquidGlass(in: Circle()) + .scaleEffect(isSwitcherPressed ? 0.9 : 1) .padding(.top, 4) .padding(.trailing, 50) .onTapGesture { onSwitchTab?() } - .onLongPressGesture(minimumDuration: 0.7) { - showRemoveCurrentTabConfirmation = true - } + .onLongPressGesture( + minimumDuration: 0.7, + maximumDistance: 44, + pressing: updateSwitcherPressState, + perform: presentRemoveTabChoices + ) .accessibilityLabel("Switch account") .accessibilityAddTraits(.isButton) } @@ -145,14 +171,22 @@ struct ContentView: View { .sheet(isPresented: $isShareSheetPresented) { ShareSheet(activityItems: [Self.testFlightURL]) } - .confirmationDialog("Remove this tab?", isPresented: $showRemoveCurrentTabConfirmation, titleVisibility: .visible) { - Button("Remove Tab", role: .destructive) { - onRemoveActiveTab?() + .confirmationDialog("Remove a tab?", isPresented: $showRemoveCurrentTabConfirmation, titleVisibility: .visible) { + ForEach(removeTabChoices) { tab in + Button(tab.removeActionTitle, role: .destructive) { + onRemoveTab?(tab) + } + } + Button("Cancel", role: .cancel) { + showRemoveCurrentTabConfirmation = false } - Button("Cancel", role: .cancel) {} } } + private var removeTabChoices: [SocialTab] { + [.bluesky, .x].filter { removableTabs.contains($0) } + } + private var shouldShowScreenTimeBadge: Bool { screenTimeTracker.secondsToday >= screenTimeBadgeThreshold } @@ -193,6 +227,20 @@ struct ContentView: View { } } } + + private func updateSwitcherPressState(_ isPressing: Bool) { + withAnimation(.spring(response: 0.18, dampingFraction: 0.72)) { + isSwitcherPressed = isPressing + } + } + + private func presentRemoveTabChoices() { + UIImpactFeedbackGenerator(style: .medium).impactOccurred() + withAnimation(.bouncy(duration: 0.42, extraBounce: 0.22)) { + isSwitcherPressed = false + } + showRemoveCurrentTabConfirmation = true + } } @@ -207,6 +255,15 @@ private extension View { } } +private extension SocialTab { + var removeActionTitle: String { + switch self { + case .x: return "Remove X / Twitter Tab" + case .bluesky: return "Remove Bluesky Tab" + } + } +} + // MARK: - Duration formatting private func formatDuration(_ seconds: TimeInterval) -> String { diff --git a/solipsistweets/solipsistweetsApp.swift b/solipsistweets/solipsistweetsApp.swift index 646c15a..09f5926 100644 --- a/solipsistweets/solipsistweetsApp.swift +++ b/solipsistweets/solipsistweetsApp.swift @@ -188,14 +188,18 @@ private struct SocialWebContainer: View { requestedURL: $requestedURL, profile: accountStore.activeProfile, switcherIcon: accountStore.nextTab?.emoji, + removableTabs: accountStore.configuredTabs, setupTabs: accountStore.missingTabs, onSwitchTab: { accountStore.switchToNextTab() requestedURL = accountStore.activeProfile.startURL }, - onRemoveActiveTab: { - accountStore.remove(accountStore.activeTab) - requestedURL = accountStore.activeProfile.startURL + onRemoveTab: { tab in + let removedActiveTab = tab == accountStore.activeTab + accountStore.remove(tab) + if removedActiveTab { + requestedURL = accountStore.activeProfile.startURL + } }, onSetupTab: { tab in accountStore.add(tab) From 45f1797e6047d6be108a134f81727af43f39a83e Mon Sep 17 00:00:00 2001 From: Chris Beiser Date: Sun, 26 Apr 2026 17:26:28 -1000 Subject: [PATCH 4/4] Add repo hooks and shared launch schemes --- AGENTS.md | 6 +- scripts/git-hooks/post-checkout | 13 +++ scripts/git-hooks/pre-commit | 36 ++++++++ scripts/install-git-hooks.sh | 16 ++++ scripts/seed-derived-data.sh | 34 +++++++ scripts/swiftlint.sh | 11 ++- solipsistweets.xcodeproj/project.pbxproj | 2 + .../xcshareddata/xcschemes/orion.xcscheme | 90 +++++++++++++++++++ .../xcschemes/solipsistweets.xcscheme | 90 +++++++++++++++++++ .../xcschemes/xcschememanagement.plist | 13 +++ 10 files changed, 309 insertions(+), 2 deletions(-) create mode 100755 scripts/git-hooks/post-checkout create mode 100755 scripts/git-hooks/pre-commit create mode 100755 scripts/install-git-hooks.sh create mode 100755 scripts/seed-derived-data.sh create mode 100644 solipsistweets.xcodeproj/xcshareddata/xcschemes/orion.xcscheme create mode 100644 solipsistweets.xcodeproj/xcshareddata/xcschemes/solipsistweets.xcscheme diff --git a/AGENTS.md b/AGENTS.md index 9d63954..f6f16c4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,11 +42,15 @@ Keep `ENABLE_USER_SCRIPT_SANDBOXING = NO`; project build phases may need access Keep DerivedData local to the worktree at `DerivedData/`. It is ignored by Git. +For a new worktree, `scripts/seed-derived-data.sh` can copy a warm sibling `DerivedData/` using APFS clone-copy semantics when available, then removes path-sensitive build state. Install the optional best-effort hooks with `scripts/install-git-hooks.sh`; the post-checkout hook seeds DerivedData if absent without blocking checkout on failure. + ## Lint SwiftLint is configured with focused safety/correctness rules in `.swiftlint.yml` and runs in strict mode. Broad size/name/shape rules and current style-only noise are disabled so formatting preferences do not drown out safety checks or block routine builds. -Run build-time lint with `scripts/swiftlint.sh build`; Xcode target phases run the same command during verification builds. Missing SwiftLint is a local warning but a CI error. Run the base config directly with `scripts/swiftlint.sh lint`. Run autofix-only style cleanup with `scripts/swiftlint.sh fix`. Keep broad style gates out of strict lint unless existing code is baselined or fixed separately. +Run build-time lint with `scripts/swiftlint.sh build`; Xcode target phases run the same command during verification builds. Missing SwiftLint is a local warning but a CI error. Run the base config directly with `scripts/swiftlint.sh lint`. Run autofix-only style cleanup with `scripts/swiftlint.sh fix`. + +Install the optional pre-commit hook with `scripts/install-git-hooks.sh`; it sets `core.hooksPath` to `scripts/git-hooks`, runs the separate `.swiftlint-autofix.yml` path with SwiftLint `--fix --format` on staged Swift files, re-stages fixes, and aborts if a staged Swift file also has unstaged edits. Keep broad style gates out of strict lint unless existing code is baselined or fixed separately. ## Source Practices diff --git a/scripts/git-hooks/post-checkout b/scripts/git-hooks/post-checkout new file mode 100755 index 0000000..5510d0f --- /dev/null +++ b/scripts/git-hooks/post-checkout @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" +if [[ -z "$repo_root" ]]; then + exit 0 +fi + +cd "$repo_root" + +if [[ ! -d "$repo_root/DerivedData" && -x "$repo_root/scripts/seed-derived-data.sh" ]]; then + "$repo_root/scripts/seed-derived-data.sh" >/dev/null 2>&1 || true +fi diff --git a/scripts/git-hooks/pre-commit b/scripts/git-hooks/pre-commit new file mode 100755 index 0000000..b71ac4c --- /dev/null +++ b/scripts/git-hooks/pre-commit @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(git rev-parse --show-toplevel)" +cd "$repo_root" + +staged_swift_files=() +while IFS= read -r -d '' staged; do + staged_swift_files+=("$staged") +done < <(git diff --cached --name-only -z --diff-filter=ACMR -- '*.swift') + +if [[ "${#staged_swift_files[@]}" -eq 0 ]]; then + exit 0 +fi + +unstaged_swift_files=() +while IFS= read -r -d '' unstaged; do + unstaged_swift_files+=("$unstaged") +done < <(git diff --name-only -z -- '*.swift') + +for ((staged_index = 0; staged_index < ${#staged_swift_files[@]}; staged_index++)); do + staged="${staged_swift_files[$staged_index]}" + for ((unstaged_index = 0; unstaged_index < ${#unstaged_swift_files[@]}; unstaged_index++)); do + unstaged="${unstaged_swift_files[$unstaged_index]}" + if [[ "$staged" == "$unstaged" ]]; then + echo "error: $staged has both staged and unstaged edits; stage or stash one side before committing." >&2 + exit 1 + fi + done +done + +"$repo_root/scripts/swiftlint.sh" autofix "${staged_swift_files[@]}" +git add -- "${staged_swift_files[@]}" + +"$repo_root/scripts/swiftlint.sh" lint-autofix "${staged_swift_files[@]}" +"$repo_root/scripts/swiftlint.sh" build "${staged_swift_files[@]}" diff --git a/scripts/install-git-hooks.sh b/scripts/install-git-hooks.sh new file mode 100755 index 0000000..7af9a6f --- /dev/null +++ b/scripts/install-git-hooks.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +cd "$repo_root" + +chmod +x "$repo_root/scripts/git-hooks/post-checkout" "$repo_root/scripts/git-hooks/pre-commit" + +if [[ "$(git config --get extensions.worktreeConfig || true)" == "true" ]]; then + git config --worktree core.hooksPath scripts/git-hooks +else + git config core.hooksPath scripts/git-hooks +fi + +echo "Installed repo-local git hooks via core.hooksPath=scripts/git-hooks." diff --git a/scripts/seed-derived-data.sh b/scripts/seed-derived-data.sh new file mode 100755 index 0000000..6f32be5 --- /dev/null +++ b/scripts/seed-derived-data.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +target_dd="${DERIVED_DATA_PATH:-$repo_root/DerivedData}" +warm_dd="${1:-}" + +if [[ -d "$target_dd" ]]; then + echo "DerivedData already exists: $target_dd" + exit 0 +fi + +if [[ -z "$warm_dd" ]]; then + sibling_root="$(dirname "$repo_root")" + warm_dd="$(find "$sibling_root" -maxdepth 3 -type d -name DerivedData -not -path "$target_dd" -print -quit 2>/dev/null || true)" +fi + +if [[ -z "$warm_dd" || ! -d "$warm_dd" ]]; then + echo "No warm DerivedData found. Run a build to create $target_dd." + exit 0 +fi + +mkdir -p "$(dirname "$target_dd")" +if ! cp -cR "$warm_dd" "$target_dd" 2>/dev/null; then + cp -R "$warm_dd" "$target_dd" +fi + +rm -rf \ + "$target_dd/Build" \ + "$target_dd/Index.noindex/Build" \ + "$target_dd"/*/Build \ + "$target_dd"/*/Index.noindex/Build + +echo "Seeded DerivedData from $warm_dd" diff --git a/scripts/swiftlint.sh b/scripts/swiftlint.sh index 9c5fbc6..6f625a7 100755 --- a/scripts/swiftlint.sh +++ b/scripts/swiftlint.sh @@ -55,8 +55,17 @@ case "$MODE" in --quiet \ "$@" ;; + lint-autofix) + swiftlint lint \ + --config "$ROOT_DIR/.swiftlint-autofix.yml" \ + --working-directory "$ROOT_DIR" \ + --strict \ + --quiet \ + --reporter xcode \ + "$@" + ;; *) - echo "usage: $0 [build|lint|fix] [paths...]" >&2 + echo "usage: $0 [build|lint|fix|autofix|lint-autofix] [paths...]" >&2 exit 64 ;; esac diff --git a/solipsistweets.xcodeproj/project.pbxproj b/solipsistweets.xcodeproj/project.pbxproj index 4874d3f..02b657c 100644 --- a/solipsistweets.xcodeproj/project.pbxproj +++ b/solipsistweets.xcodeproj/project.pbxproj @@ -288,6 +288,7 @@ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGNING_ALLOWED[sdk=iphonesimulator*]" = NO; COPY_PHASE_STRIP = NO; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; @@ -352,6 +353,7 @@ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGNING_ALLOWED[sdk=iphonesimulator*]" = NO; COPY_PHASE_STRIP = NO; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; diff --git a/solipsistweets.xcodeproj/xcshareddata/xcschemes/orion.xcscheme b/solipsistweets.xcodeproj/xcshareddata/xcschemes/orion.xcscheme new file mode 100644 index 0000000..fbfff24 --- /dev/null +++ b/solipsistweets.xcodeproj/xcshareddata/xcschemes/orion.xcscheme @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/solipsistweets.xcodeproj/xcshareddata/xcschemes/solipsistweets.xcscheme b/solipsistweets.xcodeproj/xcshareddata/xcschemes/solipsistweets.xcscheme new file mode 100644 index 0000000..182dd29 --- /dev/null +++ b/solipsistweets.xcodeproj/xcshareddata/xcschemes/solipsistweets.xcscheme @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/solipsistweets.xcodeproj/xcuserdata/chrisbeiser.xcuserdatad/xcschemes/xcschememanagement.plist b/solipsistweets.xcodeproj/xcuserdata/chrisbeiser.xcuserdatad/xcschemes/xcschememanagement.plist index 7b3c62d..c6ffe87 100644 --- a/solipsistweets.xcodeproj/xcuserdata/chrisbeiser.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/solipsistweets.xcodeproj/xcuserdata/chrisbeiser.xcuserdatad/xcschemes/xcschememanagement.plist @@ -20,5 +20,18 @@ 2 + SuppressBuildableAutocreation + + 160494052E6507430073407B + + primary + + + 16EE1E362E7E546700B03BC8 + + primary + + +