Compare commits

...

9 commits
1.5.0 ... main

Author SHA1 Message Date
Otavio Cordeiro
59f79e55c5 Add ADR-017: Direct Distribution Outside App Store 2026-01-22 05:30:17 -03:00
Otavio Cordeiro
46fd069259 Break EditorView into smaller methods 2026-01-15 06:41:40 -03:00
Otavio Cordeiro
d4aecbe8ed Update Swift version used by SwiftFormat 2026-01-14 13:10:46 -03:00
Otavio Cordeiro
3ed032e8df Run SwiftFormat without modifying files 2026-01-14 13:03:05 -03:00
Otavio Cordeiro
afdd145a63 Add Build Phases for both SwiftLint and SwiftFormat 2026-01-14 12:30:53 -03:00
Otavio Cordeiro
ee174ab836 Add SwiftLint 2026-01-14 12:30:53 -03:00
Otavio Cordeiro
d3b596c26f Split GitHub Actions in separate files 2026-01-13 12:36:24 -03:00
Otavio Cordeiro
1a105917b2 Hide copy and share actions for draft posts 2026-01-05 12:56:25 -03:00
Otavio Cordeiro
2de32c7160 Add steps to install and upgrade to release notes 2026-01-05 12:52:18 -03:00
74 changed files with 707 additions and 266 deletions

45
.github/workflows/build.yml vendored Normal file
View file

@ -0,0 +1,45 @@
name: Build
on:
pull_request:
branches: [ main ]
jobs:
build:
name: Build OMG
runs-on: macos-26
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Select Xcode version
uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: '26.1.1'
- name: Show Xcode version
run: xcodebuild -version
- name: Cache Swift Package Manager
uses: actions/cache@v4
with:
path: |
.build
~/Library/Developer/Xcode/DerivedData
key: ${{ runner.os }}-spm-${{ hashFiles('**/Package.resolved') }}
restore-keys: |
${{ runner.os }}-spm-
- name: Resolve Swift Package dependencies
run: xcodebuild -resolvePackageDependencies -project OMG.xcodeproj -scheme OMG
- name: Build OMG
run: |
xcodebuild build \
-project OMG.xcodeproj \
-scheme OMG \
-configuration Debug \
-destination 'platform=macOS' \
| xcpretty || exit 1
continue-on-error: false

View file

@ -91,6 +91,27 @@ jobs:
echo "No pull requests were merged in this release."
echo ""
fi
echo "## Installation"
echo ""
echo "**Download directly from GitHub Releases**"
echo ""
echo "Download the \`.zip\` file from this release, extract it, and drag OMG.app to your Applications folder."
echo ""
echo "**Install via [Homebrew](https://brew.sh)**"
echo ""
echo "\`\`\`bash"
echo "brew tap otaviocc/apps"
echo "brew install --cask triton"
echo "\`\`\`"
echo ""
echo "**Upgrade via Homebrew**"
echo ""
echo "If you installed Triton using Homebrew, upgrade to the latest version with:"
echo ""
echo "\`\`\`bash"
echo "brew upgrade --cask triton"
echo "\`\`\`"
} > release_notes.md
cat release_notes.md

20
.github/workflows/swiftformat.yml vendored Normal file
View file

@ -0,0 +1,20 @@
name: SwiftFormat Check
on:
pull_request:
branches: [ main ]
jobs:
swiftformat:
name: Code Formatting Check
runs-on: macos-26
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install swiftformat
run: brew install swiftformat
- name: Check code formatting
run: swiftformat --lint .

20
.github/workflows/swiftlint.yml vendored Normal file
View file

@ -0,0 +1,20 @@
name: SwiftLint Check
on:
pull_request:
branches: [ main ]
jobs:
swiftlint:
name: SwiftLint
runs-on: macos-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install SwiftLint
run: brew install swiftlint
- name: Run SwiftLint
run: swiftlint lint --reporter github-actions-logging

View file

@ -1,14 +1,12 @@
name: Pull Request Checks
name: Unit Tests
on:
pull_request:
branches:
- main
- develop
branches: [ main ]
jobs:
build-and-format:
name: Build and Format Check
test:
name: Run Unit Tests
runs-on: macos-26
steps:
@ -23,17 +21,6 @@ jobs:
- name: Show Xcode version
run: xcodebuild -version
- name: Install swiftformat
run: brew install swiftformat
- name: Check code formatting
run: |
swiftformat --lint .
if [ $? -ne 0 ]; then
echo "❌ Code formatting issues detected. Please run 'swiftformat .' locally to fix."
exit 1
fi
- name: Cache Swift Package Manager
uses: actions/cache@v4
with:
@ -47,16 +34,6 @@ jobs:
- name: Resolve Swift Package dependencies
run: xcodebuild -resolvePackageDependencies -project OMG.xcodeproj -scheme OMG
- name: Build OMG
run: |
xcodebuild build \
-project OMG.xcodeproj \
-scheme OMG \
-configuration Debug \
-destination 'platform=macOS' \
| xcpretty || exit 1
continue-on-error: false
- name: Run OMG Tests
run: |
xcodebuild test \

View file

@ -50,7 +50,7 @@
--ifdef indent
# MARK: - SwiftUI and Modern Swift Features
--swift-version 6.0
--swift-version 6.2
--sort-swiftui-properties first-appearance-sort
--some-any true
--short-optionals always

64
.swiftlint.yml Normal file
View file

@ -0,0 +1,64 @@
# Minimal SwiftLint configuration
# SwiftFormat handles all formatting, so SwiftLint focuses on code quality and best practices
disabled_rules:
- line_length
- opening_brace
- function_parameter_count
- nesting
- large_tuple
- type_name
- force_unwrapping
- blanket_disable_command
opt_in_rules:
- array_init
- contains_over_first_not_nil
- convenience_type
- discouraged_assert
- discouraged_object_literal
- empty_count
- empty_string
- empty_xctest_method
- explicit_init
- fatal_error_message
- first_where
- force_cast
- force_try
- implicit_return
- joined_default_parameter
- last_where
- legacy_random
- lower_acl_than_parent
- multiline_function_chains
- multiline_parameters
- multiline_parameters_brackets
- no_fallthrough_only
- operator_usage_whitespace
- overridden_super_call
- prohibited_super_call
- redundant_nil_coalescing
- single_test_class
- sorted_first_last
- static_operator
- switch_case_alignment
- trailing_closure
- unavailable_function
- unneeded_parentheses_in_closure_argument
- unused_control_flow_label
- vertical_whitespace_closing_braces
- xct_specific_matcher
- xctfail_message
- yoda_condition
multiline_parameters:
allows_single_line: false
excluded:
- .build
- "**/.build"
- DerivedData
- Pods
- Carthage
- .git
- node_modules

View file

@ -0,0 +1,102 @@
# ADR-017: Direct Distribution Outside App Store
**Status:** Accepted
**Date:** 2025-01-22
**Context:**
When deciding how to distribute the macOS application, I evaluated two primary distribution channels:
1. **Mac App Store:** Apple's official marketplace with built-in distribution and discovery
2. **Direct distribution:** Downloadable DMG/PKG outside the App Store (sideloading)
Key considerations influencing this decision:
- **Development velocity:** Ability to iterate and release updates quickly
- **Review process overhead:** Time and friction introduced by App Store review cycles
- **Cost implications:** Additional expenses beyond existing Apple Developer Program membership
- **Security and trust:** Maintaining user confidence through proper code signing and notarization
- **Control over distribution:** Flexibility in release timing and update deployment
The application requires an omg.lol account for authentication and core functionality. Apple's App Store review process requires providing reviewers with a functional test account, which would necessitate purchasing an additional omg.lol address specifically for review purposes.
**Decision:**
I chose to distribute the application directly outside the Mac App Store through downloadable DMG or PKG installers. Users download and install the application manually (sideloading), bypassing the App Store entirely.
**Distribution Approach:**
1. **Direct downloads:** Hosted files available from GitHub releases
2. **Code signing:** Application binary signed with Apple Developer ID certificate
3. **Notarization:** Binary submitted to Apple's notarization service for malware scanning
4. **Gatekeeper compliance:** Notarized app passes macOS Gatekeeper checks on first launch
5. **Manual updates:** Users download and install updates manually
**Security Measures (Still Applied):**
Despite not being distributed through the App Store, the application maintains the same security practices:
- **Code signing required:** Binary must be signed with valid Developer ID
- **Notarization required:** Apple's automated security scan must pass before distribution
- **Gatekeeper verification:** macOS verifies signature and notarization on first launch
- **Static analysis:** Binary undergoes Apple's automated malware and security checks
- **Developer accountability:** Apple Developer ID ties the application to verified developer account
The only difference from App Store distribution is the absence of manual human review. All automated security checks, signing requirements, and notarization processes remain identical.
**Consequences:**
### Positive
- **Faster iteration cycles:** No waiting for App Store review approval (typically 24-48 hours per submission)
- **Immediate releases:** Updates can be deployed as soon as they're ready
- **No review friction:** Avoid potential rejections requiring code changes and resubmission
- **Cost savings:** No additional omg.lol subscription required for App Store review account ($20/year saved)
- **Developer Program only:** Single $99/year Apple Developer membership covers all requirements
- **Release control:** Full control over timing, rollback, and phased rollouts
- **No guideline restrictions:** Freedom from App Store Review Guidelines constraints (beyond security requirements)
- **Testing flexibility:** No need to maintain separate review-specific test accounts
### Negative
- **No App Store discovery:** Users cannot find the app through Mac App Store search
- **Manual update flow:** Users must manually check for and install updates (unless in-app updater implemented)
- **Trust barrier:** Some users hesitate to install applications outside the App Store
- **Distribution responsibility:** Must host files on reliable infrastructure (GitHub releases)
- **No App Store features:** Cannot leverage TestFlight, automatic updates, or App Store metadata/screenshots for marketing
### Neutral
- **Notarization turnaround:** Apple notarization still required but typically completes within minutes (faster than full review)
- **Gatekeeper warnings:** First launch shows standard "downloaded from internet" warning (expected for all non-App Store apps)
- **Marketing channels:** Must rely on direct marketing, social media, and community rather than App Store presence
**Why This Doesn't Compromise Quality:**
Direct distribution does not mean lower security or quality standards. The application still undergoes:
1. **Developer ID signing:** Cryptographic signature proving developer identity
2. **Notarization:** Apple's automated security analysis scanning for malware and policy violations
3. **Gatekeeper checks:** macOS verifies the app's signature and notarization before first launch
4. **Same binary standards:** Hardened runtime, code signing requirements identical to App Store builds
The primary difference is the absence of manual human review, not the absence of security checks. App Store review primarily enforces guideline compliance (UI/UX standards, business model rules, content policies) rather than discovering security issues that automated tools miss.
**Cost-Benefit Analysis:**
- **Current cost:** $99/year Apple Developer Program (required for notarization and signing)
- **App Store additional cost:** $20/year omg.lol test account (20% overhead)
- **App Store time cost:** 24-48 hours per release (blocks urgent fixes)
- **Development velocity value:** Immediate releases enable faster user feedback and bug fixes
For a single-developer project with an existing account requirement, avoiding the App Store review process provides better ROI through faster iteration and lower operational overhead.
**Related Decisions:**
- Future consideration: Could revisit App Store distribution if user acquisition through App Store becomes strategically valuable
- Potential automation: Could implement Sparkle framework or similar for automated update checks
**Notes:**
This decision prioritizes development velocity and cost efficiency while maintaining identical security standards through code signing and notarization. Direct distribution is a common and legitimate approach for many professional macOS applications (Homebrew, VS Code, Docker Desktop, etc.).

View file

@ -76,6 +76,11 @@ Standardized context menu structure for content items using native ShareLink for
### [ADR-016: SwiftUI Previews with Mother Objects](ADR-016-swiftui-previews-with-mother-objects.md)
Use of Mother Object pattern for creating reusable test fixtures that support SwiftUI Previews across 90% of views, enabling rapid UI development with realistic data.
## Distribution
### [ADR-017: Direct Distribution Outside App Store](ADR-017-direct-distribution-outside-app-store.md)
Decision to distribute the application directly through downloadable installers outside the Mac App Store, prioritizing development velocity and cost efficiency while maintaining security through code signing and notarization.
## Contributing
When making significant architectural decisions:

View file

@ -153,6 +153,8 @@
isa = PBXNativeTarget;
buildConfigurationList = EAC944E3295E56EA004EE34F /* Build configuration list for PBXNativeTarget "OMG" */;
buildPhases = (
EA67B2142F17DE3400A145FB /* SwiftFormat */,
EA67B2152F17DF3300A145FB /* SwiftLint */,
EAC944D0295E56E9004EE34F /* Sources */,
EAC944D1295E56E9004EE34F /* Frameworks */,
EAC944D2295E56E9004EE34F /* Resources */,
@ -239,6 +241,47 @@
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
EA67B2142F17DE3400A145FB /* SwiftFormat */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
);
name = SwiftFormat;
outputFileListPaths = (
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/zsh;
shellScript = "#!/bin/zsh\n\n# Add Homebrew paths to PATH\nexport PATH=\"/opt/homebrew/bin:/usr/local/bin:$PATH\"\n\n# Exit successfully if swiftformat is not installed\nif ! command -v swiftformat &> /dev/null; then\n echo \"warning: SwiftFormat not installed, skipping formatting\"\n exit 0\nfi\n\n# Only run on actual builds, not indexing\nif [[ \"${ACTION}\" == \"indexbuild\" ]]; then\n exit 0\nfi\n\n# Change to source root to ensure .swiftformat is accessible\ncd \"${SRCROOT}\" || exit 0\n\n# Run SwiftFormat on the project directory\nswiftformat . --lint --lenient 2>&1 | while IFS= read -r line; do\n echo \"${line}\"\ndone\n\necho \"SwiftFormat completed successfully\"\nexit 0\n";
};
EA67B2152F17DF3300A145FB /* SwiftLint */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
);
name = SwiftLint;
outputFileListPaths = (
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/zsh;
shellScript = "#!/bin/zsh\n\n# Add Homebrew paths to PATH\nexport PATH=\"/opt/homebrew/bin:/usr/local/bin:$PATH\"\n\n# Exit successfully if swiftlint is not installed\nif ! command -v swiftlint &> /dev/null; then\n echo \"warning: SwiftLint not installed, skipping linting\"\n exit 0\nfi\n\n# Only run on actual builds, not indexing\nif [[ \"${ACTION}\" == \"indexbuild\" ]]; then\n exit 0\nfi\n\n# Change to source root to ensure .swiftlint.yml is accessible\ncd \"${SRCROOT}\" || exit 0\n\n# Run SwiftLint and report violations as Xcode warnings/errors\nswiftlint lint --quiet --reporter xcode\n\nexit 0\n";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
EAC944D0295E56E9004EE34F /* Sources */ = {
isa = PBXSourcesBuildPhase;
@ -390,6 +433,7 @@
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
ENABLE_USER_SELECTED_FILES = readonly;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "OMG/Supporting Files/Info.plist";
@ -439,6 +483,7 @@
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
ENABLE_USER_SELECTED_FILES = readonly;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "OMG/Supporting Files/Info.plist";

View file

@ -207,6 +207,7 @@ struct TritonEnvironment: TritonEnvironmentProtocol {
)
}
// swiftlint:disable function_body_length
init(
authSessionServiceFactory: any AuthSessionServiceFactoryProtocol,
sessionServiceFactory: any SessionServiceFactoryProtocol,
@ -233,11 +234,9 @@ struct TritonEnvironment: TritonEnvironmentProtocol {
allocation: .static
) { container in
let authSessionService = container.resolve() as any AuthSessionServiceProtocol
return networkClient.makeOMGAPIClient(
authTokenProvider: {
return networkClient.makeOMGAPIClient {
await authSessionService.accessToken
}
)
}
container.register(
@ -375,4 +374,5 @@ struct TritonEnvironment: TritonEnvironmentProtocol {
)
}
}
// swiftlint:enable function_body_length
}

View file

@ -41,8 +41,8 @@ struct TritonScene: Scene {
#endif
.environment(makeAccountUpdateService())
.handlesExternalEvents(
preferring: Set(arrayLiteral: "viewer"),
allowing: Set(arrayLiteral: "*")
preferring: ["viewer"],
allowing: ["*"]
)
.onAppear {
environment

View file

@ -70,13 +70,11 @@ actor AccountUpdateRepository: AccountUpdateRepositoryProtocol {
await fetchAccountInformation()
}
for await isLoggedIn in authSessionService.observeLoginState() {
if isLoggedIn {
for await isLoggedIn in authSessionService.observeLoginState() where isLoggedIn {
await fetchAccountInformation()
}
}
}
}
private func fetchAccountInformation() async {
guard await authSessionService.isLoggedIn else { return }

View file

@ -25,13 +25,11 @@ final class AuthAppViewModel {
let currentState = await authSessionService.isLoggedIn
isLoggedIn = currentState
for await loginState in authSessionService.observeLoginState() {
if isLoggedIn != loginState {
for await loginState in authSessionService.observeLoginState() where isLoggedIn != loginState {
isLoggedIn = loginState
}
}
}
}
deinit {
observationTask?.cancel()

View file

@ -81,7 +81,7 @@ final class AuthSessionServiceTests: XCTestCase {
)
}
func testObserveLoginStateYieldsCurrentState() async {
func testObserveLoginStateYieldsCurrentState() async throws {
// Given
let initialState = await service.isLoggedIn
@ -97,9 +97,8 @@ final class AuthSessionServiceTests: XCTestCase {
var iterator = stream.makeAsyncIterator()
let firstValue = await iterator.next()
XCTAssertEqual(
firstValue,
false,
XCTAssertFalse(
try XCTUnwrap(firstValue),
"It should yielded the first value correctly"
)
}

View file

@ -185,10 +185,10 @@ public struct SelectionToolbarItem<Option: Hashable & CaseIterable>: View {
DropdownMenuView(
options: options,
selection: selection,
itemLabel: itemLabel,
label: {
AnyView(style.makeLabel(helpText: helpText ?? style.defaultHelpText))
itemLabel: itemLabel
) {
let helpText = helpText ?? style.defaultHelpText
return AnyView(style.makeLabel(helpText: helpText))
}
)
}
}

View file

@ -78,14 +78,16 @@ public struct TagListView: View {
#Preview("Regular Tags") {
TagListView(
tags: ["swift", "ios", "macos"],
helpText: { "Select \($0)" }
) { _ in }
helpText: { "Select \($0)" },
action: { _ in }
)
}
#Preview("Remove Tags") {
TagListView(
tags: ["swift", "ios", "macos"],
style: .remove,
helpText: { "Remove \($0)" }
) { _ in }
helpText: { "Remove \($0)" },
action: { _ in }
)
}

View file

@ -1,6 +1,8 @@
import Testing
@testable import FoundationExtensions
// swiftlint:disable file_length type_body_length
@Suite("ArrayContains Tests")
struct ArrayContainsTests {
@ -535,3 +537,5 @@ struct ArrayContainsTests {
)
}
}
// swiftlint:enable file_length type_body_length

View file

@ -90,7 +90,7 @@ struct StringSlugTests {
// Then
#expect(
result == "",
result.isEmpty,
"It should handle empty string"
)
}
@ -105,7 +105,7 @@ struct StringSlugTests {
// Then
#expect(
result == "",
result.isEmpty,
"It should handle string with only whitespace"
)
}

View file

@ -2,6 +2,8 @@ import Foundation
import Testing
@testable import FoundationExtensions
// swiftlint:disable file_length type_body_length
@Suite("StringWeblog Tests")
struct StringWeblogTests {
@ -496,3 +498,5 @@ struct StringWeblogTests {
)
}
}
// swiftlint:enable file_length type_body_length

View file

@ -41,6 +41,7 @@ struct NowEnvironment {
)
}
// swiftlint:disable function_body_length
init(
networkServiceFactory: NowNetworkServiceFactoryProtocol,
persistenceServiceFactory: NowPersistenceServiceFactoryProtocol,
@ -113,4 +114,5 @@ struct NowEnvironment {
)
}
}
// swiftlint:enable function_body_length
}

View file

@ -12,12 +12,12 @@
count: Int,
in container: ModelContainer
) {
for i in 0..<count {
for index in 0..<count {
let now = Now(
listed: true,
markdown: "Foobar \(i)",
markdown: "Foobar \(index)",
submitted: true,
timestamp: 123_123 * Double(i),
timestamp: 123_123 * Double(index),
address: "otaviocc"
)

View file

@ -17,8 +17,15 @@
}
}
func fetchNowPage(for address: String) async throws {}
func updateNowPage(address: String, content: String, listed: Bool) async throws {}
func fetchNowPage(
for address: String
) async throws {}
func updateNowPage(
address: String,
content: String,
listed: Bool
) async throws {}
}
// MARK: - Public

View file

@ -37,7 +37,12 @@
// MARK: - Public
func fetchNowPage() async throws {}
func updateNowPage(address: String, content: String, isListed: Bool) async throws {}
func updateNowPage(
address: String,
content: String,
isListed: Bool
) async throws {}
}
// MARK: - Public

View file

@ -3,5 +3,6 @@ import XCTest
final class NowTests: XCTestCase {
// swiftlint:disable:next empty_xctest_method
func testExample() throws {}
}

View file

@ -5,16 +5,4 @@ public struct CreateOrUpdatePasteRequest: Encodable, Sendable {
let title: String
let content: String
let listed: Bool
// MARK: - Lifecycle
init(
title: String,
content: String,
listed: Bool
) {
self.title = title
self.content = content
self.listed = listed
}
}

View file

@ -5,16 +5,4 @@ public struct CreatePURLRequest: Encodable, Sendable {
let address: String
let name: String
let url: String
// MARK: - Lifecycle
init(
address: String,
name: String,
url: String
) {
self.address = address
self.name = name
self.url = url
}
}

View file

@ -4,14 +4,4 @@ public struct UpdateNowPageRequest: Encodable, Sendable {
let content: String
let listed: Int
// MARK: - Lifecycle
init(
content: String,
listed: Int
) {
self.content = content
self.listed = listed
}
}

View file

@ -4,14 +4,4 @@ public struct UpdateWebpageRequest: Encodable, Sendable {
let content: String
let publish: Bool
// MARK: - Lifecycle
init(
content: String,
publish: Bool
) {
self.content = content
self.publish = publish
}
}

View file

@ -5,12 +5,4 @@ public struct UploadPictureRequest: Encodable, Sendable {
// MARK: - Properties
let pic: String
// MARK: - Lifecycle
init(
pic: String
) {
self.pic = pic
}
}

View file

@ -40,22 +40,22 @@ struct AuthRequestFactoryTests {
)
#expect(
queryItems.contains(where: { $0.name == "client_id" }),
queryItems.contains { $0.name == "client_id" },
"It should include client_id parameter"
)
#expect(
queryItems.contains(where: { $0.name == "scope" && $0.value == "everything" }),
queryItems.contains { $0.name == "scope" && $0.value == "everything" },
"It should include scope parameter with 'everything' value"
)
#expect(
queryItems.contains(where: { $0.name == "response_type" && $0.value == "code" }),
queryItems.contains { $0.name == "response_type" && $0.value == "code" },
"It should include response_type parameter with 'code' value"
)
#expect(
queryItems.contains(where: { $0.name == "redirect_uri" }),
queryItems.contains { $0.name == "redirect_uri" },
"It should include redirect_uri parameter"
)
}
@ -82,27 +82,27 @@ struct AuthRequestFactoryTests {
let queryItems = request.queryItems
#expect(
queryItems.contains(where: { $0.name == "client_id" }),
queryItems.contains { $0.name == "client_id" },
"It should include client_id query parameter"
)
#expect(
queryItems.contains(where: { $0.name == "client_secret" }),
queryItems.contains { $0.name == "client_secret" },
"It should include client_secret query parameter"
)
#expect(
queryItems.contains(where: { $0.name == "redirect_uri" }),
queryItems.contains { $0.name == "redirect_uri" },
"It should include redirect_uri query parameter"
)
#expect(
queryItems.contains(where: { $0.name == "code" && $0.value == authCode }),
queryItems.contains { $0.name == "code" && $0.value == authCode },
"It should include code query parameter with provided auth code"
)
#expect(
queryItems.contains(where: { $0.name == "scope" && $0.value == "everything" }),
queryItems.contains { $0.name == "scope" && $0.value == "everything" },
"It should include scope query parameter"
)
}

View file

@ -232,7 +232,7 @@ struct PicsRequestFactoryTests {
}
@Test("It should create picture edit request with empty tags array")
func makeEditPictureRequest_withOnlyWithEmptyTagsArray_createsRequest() {
func makeEditPictureRequest_withOnlyWithEmptyTagsArray_createsRequest() throws {
// Given
let address = "dave"
let pictureID = "pic-456"
@ -256,23 +256,26 @@ struct PicsRequestFactoryTests {
"It should use PUT method"
)
let body = try #require(request.body)
let bodyTags = try #require(body.tags)
#expect(
request.body?.tags == "",
bodyTags.isEmpty == true,
"It should include tags in request body"
)
#expect(
request.body?.caption == nil,
body.caption == nil,
"It should have nil caption when not provided"
)
#expect(
request.body?.alt == nil,
body.alt == nil,
"It should have nil alt text when not provided"
)
#expect(
request.body?.isHidden == nil,
body.isHidden == nil,
"It should have nil isHidden when not provided"
)
}

View file

@ -43,6 +43,7 @@ struct PURLsEnvironment {
)
}
// swiftlint:disable function_body_length
init(
networkServiceFactory: PURLsNetworkServiceFactoryProtocol,
persistenceServiceFactory: PURLsPersistenceServiceFactoryProtocol,
@ -123,4 +124,5 @@ struct PURLsEnvironment {
)
}
}
// swiftlint:enable function_body_length
}

View file

@ -9,8 +9,14 @@
private final class FakeClipboardService: ClipboardServiceProtocol {
func copy(_ string: String) {}
func copy(_ data: Data, type: String) {}
func copy(
_ string: String
) {}
func copy(
_ data: Data,
type: String
) {}
}
// MARK: - Public

View file

@ -13,10 +13,10 @@
count: Int,
in container: ModelContainer
) {
for i in 0..<count {
for index in 0..<count {
let purl = PURL(
name: "purl\(i)",
url: URL(string: "http://subdomain\(i).otavio.lol")!,
name: "purl\(index)",
url: URL(string: "http://subdomain\(index).otavio.lol")!,
address: "otaviocc"
)

View file

@ -17,9 +17,20 @@
}
}
func fetchPURLs(for address: String) {}
func addPURL(address: String, name: String, url: String) {}
func deletePURL(address: String, name: String) async throws {}
func fetchPURLs(
for address: String
) {}
func addPURL(
address: String,
name: String,
url: String
) {}
func deletePURL(
address: String,
name: String
) async throws {}
}
// MARK: - Public

View file

@ -37,8 +37,17 @@
// MARK: - Public
func fetchPURLs() {}
func addPURL(address: String, name: String, url: String) {}
func deletePURL(address: String, name: String) async throws {}
func addPURL(
address: String,
name: String,
url: String
) {}
func deletePURL(
address: String,
name: String
) async throws {}
}
// MARK: - Public

View file

@ -3,5 +3,6 @@ import XCTest
final class PURLsTests: XCTestCase {
// swiftlint:disable:next empty_xctest_method
func testExample() throws {}
}

View file

@ -43,6 +43,7 @@ struct PastebinEnvironment {
)
}
// swiftlint:disable function_body_length
init(
networkServiceFactory: PastebinNetworkServiceFactoryProtocol,
persistenceServiceFactory: PastebinPersistenceServiceFactoryProtocol,
@ -123,4 +124,5 @@ struct PastebinEnvironment {
)
}
}
// swiftlint:enable function_body_length
}

View file

@ -10,7 +10,11 @@
private final class FakeClipboardService: ClipboardServiceProtocol {
func copy(_ string: String) {}
func copy(_ data: Data, type: String) {}
func copy(
_ data: Data,
type: String
) {}
}
// MARK: - Public

View file

@ -12,13 +12,13 @@
count: Int,
in container: ModelContainer
) {
for i in 0..<count {
for index in 0..<count {
let paste = Paste(
title: "paste\(i).md",
title: "paste\(index).md",
content: "hello, world!",
timestamp: 123_123_123,
address: "otaviocc",
listed: i % 2 == 0
listed: index % 2 == 0
)
container.mainContext.insert(

View file

@ -17,9 +17,21 @@
}
}
func fetchPastes(for address: String) async throws {}
func createOrUpdatePaste(address: String, title: String, content: String, listed: Bool) async throws {}
func deletePaste(address: String, title: String) async throws {}
func fetchPastes(
for address: String
) async throws {}
func createOrUpdatePaste(
address: String,
title: String,
content: String,
listed: Bool
) async throws {}
func deletePaste(
address: String,
title: String
) async throws {}
}
// MARK: - Public

View file

@ -37,8 +37,18 @@
// MARK: - Public
func fetchPastes() async throws {}
func createOrUpdatePaste(address: String, title: String, content: String, isListed: Bool) async throws {}
func deletePaste(address: String, title: String) async throws {}
func createOrUpdatePaste(
address: String,
title: String,
content: String,
isListed: Bool
) async throws {}
func deletePaste(
address: String,
title: String
) async throws {}
}
// MARK: - Public

View file

@ -3,5 +3,6 @@ import XCTest
final class PastebinTests: XCTestCase {
// swiftlint:disable:next empty_xctest_method
func testExample() throws {}
}

View file

@ -42,6 +42,7 @@ struct PicsEnvironment {
)
}
// swiftlint:disable function_body_length
init(
networkServiceFactory: PicsNetworkServiceFactoryProtocol,
persistenceServiceFactory: PicsPersistenceServiceFactoryProtocol,
@ -119,4 +120,5 @@ struct PicsEnvironment {
)
}
}
// swiftlint:enable function_body_length
}

View file

@ -9,8 +9,14 @@
private final class FakeClipboardService: ClipboardServiceProtocol {
func copy(_ string: String) {}
func copy(_ data: Data, type: String) {}
func copy(
_ string: String
) {}
func copy(
_ data: Data,
type: String
) {}
}
// MARK: - Public

View file

@ -12,8 +12,15 @@
// MARK: - Public
func fetchPictures(for address: String) async throws -> [PictureResponse] { [] }
func deletePicture(address: String, pictureID: String) async throws {}
func fetchPictures(
for address: String
) async throws -> [PictureResponse] { [] }
func deletePicture(
address: String,
pictureID: String
) async throws {}
func updatePicture(
address: String,
pictureID: String,
@ -21,6 +28,7 @@
alt: String,
tags: [String]
) async throws {}
func uploadPicture(
address: String,
data: Data,

View file

@ -39,7 +39,12 @@
// MARK: - Public
func fetchPictures() async throws {}
func deletePicture(address: String, pictureID: String) async throws {}
func deletePicture(
address: String,
pictureID: String
) async throws {}
func updatePicture(
address: String,
pictureID: String,
@ -47,6 +52,7 @@
alt: String,
tags: [String]
) async throws {}
func uploadPicture(
address: String,
data: Data,

View file

@ -13,9 +13,9 @@
count: Int = 3,
in container: ModelContainer
) {
for i in 0..<count {
for index in 0..<count {
SomePicture.makePicture(
created: Double(i * i),
created: Double(index * index),
in: container
)
}

View file

@ -98,12 +98,13 @@ struct EditPictureView: View {
if !viewModel.suggestedTags.isEmpty {
TagListView(
tags: viewModel.suggestedTags,
helpText: { "Add existing tag '\($0)'" }
) { tag in
helpText: { "Add existing tag '\($0)'" },
action: { tag in
withAnimation {
viewModel.addTag(tag)
}
}
)
}
}
@ -113,12 +114,13 @@ struct EditPictureView: View {
TagListView(
tags: viewModel.tags,
style: .remove,
helpText: { "Remove tag '\($0)'" }
) { tag in
helpText: { "Remove tag '\($0)'" },
action: { tag in
withAnimation {
viewModel.removeTag(tag)
}
}
)
}
}

View file

@ -21,7 +21,7 @@ struct PicturesListView: View {
_pictures = .init(viewModel.fetchDescriptor())
}
// MARK; - Public
// MARK: - Public
var body: some View {
Group {

View file

@ -209,12 +209,13 @@ struct UploadView: View {
if !viewModel.suggestedTags.isEmpty {
TagListView(
tags: viewModel.suggestedTags,
helpText: { "Add existing tag '\($0)'" }
) { tag in
helpText: { "Add existing tag '\($0)'" },
action: { tag in
withAnimation(.easeInOut(duration: 0.2)) {
viewModel.addTag(tag)
}
}
)
}
}
@ -224,12 +225,13 @@ struct UploadView: View {
TagListView(
tags: viewModel.tags,
style: .remove,
helpText: { "Remove tag '\($0)'" }
) { tag in
helpText: { "Remove tag '\($0)'" },
action: { tag in
withAnimation(.easeInOut(duration: 0.2)) {
viewModel.removeTag(tag)
}
}
)
}
}

View file

@ -42,6 +42,7 @@ struct StatusEnvironment {
)
}
// swiftlint:disable function_body_length
init(
repositoryFactory: StatusRepositoryFactoryProtocol,
networkServiceFactory: StatusNetworkServiceFactoryProtocol,
@ -120,4 +121,5 @@ struct StatusEnvironment {
)
}
}
// swiftlint:enable function_body_length
}

View file

@ -9,8 +9,14 @@
private final class FakeClipboardService: ClipboardServiceProtocol {
func copy(_ string: String) {}
func copy(_ data: Data, type: String) {}
func copy(
_ string: String
) {}
func copy(
_ data: Data,
type: String
) {}
}
// MARK: - Public

View file

@ -12,11 +12,11 @@
count: Int,
in container: ModelContainer
) {
for i in 0..<count {
for index in 0..<count {
let status = Status(
username: "user\(i)",
statusID: "(i)",
timestamp: Double(i),
username: "user\(index)",
statusID: "(index)",
timestamp: Double(index),
icon: "🤣",
content: "Nulla purus urna, bibendum nec purus."
)

View file

@ -45,9 +45,10 @@ public struct StatusApp: View {
ToolbarItemGroup {
SelectionToolbarItem(
options: StatusListFilter.allCases,
selection: $filter,
itemLabel: { $0.localizedTitle }
)
selection: $filter
) { label in
label.localizedTitle
}
.selectionToolbarItemStyle(FilterSelectionToolbarItemStyle())
}

View file

@ -43,7 +43,13 @@ struct ClipboardService: ClipboardServiceProtocol {
service.copy(string)
}
func copy(_ data: Data, type: String) {
service.copy(data, type: type)
func copy(
_ data: Data,
type: String
) {
service.copy(
data,
type: type
)
}
}

View file

@ -17,7 +17,10 @@
)
}
func copy(_ data: Data, type: String) {
func copy(
_ data: Data,
type: String
) {
NSPasteboard
.general
.clearContents()

View file

@ -42,6 +42,7 @@ struct WeblogEnvironment {
)
}
// swiftlint:disable function_body_length
init(
networkServiceFactory: WeblogNetworkServiceFactoryProtocol,
persistenceServiceFactory: WeblogPersistenceServiceFactoryProtocol,
@ -119,4 +120,5 @@ struct WeblogEnvironment {
)
}
}
// swiftlint:enable function_body_length
}

View file

@ -9,8 +9,14 @@
private final class FakeClipboardService: ClipboardServiceProtocol {
func copy(_ string: String) {}
func copy(_ data: Data, type: String) {}
func copy(
_ string: String
) {}
func copy(
_ data: Data,
type: String
) {}
}
// MARK: - Public

View file

@ -27,14 +27,14 @@
}
static func makeEntryResponses(count: Int = 5) -> [EntryResponse] {
(0..<count).map { i in
(0..<count).map { index in
makeEntryResponse(
id: "entry-\(i)",
location: "test-entry-\(i)",
date: Double(1_700_000_000 + (i * 86400)),
status: i % 3 == 0 ? "draft" : "published",
title: "Test Entry \(i)",
body: "Content for test entry \(i)"
id: "entry-\(index)",
location: "test-entry-\(index)",
date: Double(1_700_000_000 + (index * 86400)),
status: index % 3 == 0 ? "draft" : "published",
title: "Test Entry \(index)",
body: "Content for test entry \(index)"
)
}
}

View file

@ -12,14 +12,14 @@
count: Int,
in container: ModelContainer
) {
for i in 0..<count {
for index in 0..<count {
let entry = WeblogEntry(
id: "entry-\(i)",
title: "Test Entry \(i)",
body: "This is the body for test entry \(i). Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
date: Double(1_700_000_000 + (i * 86400)),
status: i % 3 == 0 ? "draft" : "published",
location: "test-entry-\(i)",
id: "entry-\(index)",
title: "Test Entry \(index)",
body: "This is the body for test entry \(index). Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
date: Double(1_700_000_000 + (index * 86400)),
status: index % 3 == 0 ? "draft" : "published",
location: "test-entry-\(index)",
address: "otaviocc"
)

View file

@ -12,11 +12,16 @@
// MARK: - Public
func fetchWeblogEntry(for address: String, entryID: String) async throws -> EntryResponse {
func fetchWeblogEntry(
for address: String,
entryID: String
) async throws -> EntryResponse {
EntryResponseMother.makeEntryResponse()
}
func fetchWeblogEntries(for address: String) async throws -> [EntryResponse] {
func fetchWeblogEntries(
for address: String
) async throws -> [EntryResponse] {
EntryResponseMother.makeEntryResponses(count: 2)
}
@ -41,7 +46,10 @@
EntryResponseMother.makeEntryResponse()
}
func deleteWeblogEntry(address: String, entryID: String) async throws {}
func deleteWeblogEntry(
address: String,
entryID: String
) async throws {}
}
// MARK: - Public

View file

@ -39,7 +39,11 @@
// MARK: - Public
func fetchEntries() async throws {}
func deleteEntry(address: String, entryID: String) async throws {}
func deleteEntry(
address: String,
entryID: String
) async throws {}
func createOrUpdateEntry(
address: String,

View file

@ -56,46 +56,18 @@ struct EditorView: View {
private func makeSidebarView() -> some View {
Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 16) {
GridRow(alignment: .firstTextBaseline) {
Text("Date")
.gridColumnAlignment(.trailing)
DatePicker(
selection: $viewModel.date,
displayedComponents: [.date]
) {}
.help("Select publication date")
makeDatePicker()
}
GridRow(alignment: .firstTextBaseline) {
Text("Time")
.gridColumnAlignment(.trailing)
DatePicker(
selection: $viewModel.date,
displayedComponents: [.hourAndMinute]
) {}
.help("Select publication time")
makeTimePicker()
}
Divider()
.foregroundStyle(Color.accentColor)
GridRow(alignment: .firstTextBaseline) {
Text("Status")
.gridColumnAlignment(.trailing)
Picker(
selection: $viewModel.status,
content: {
ForEach(WeblogEntryStatus.allCases) { status in
Text(status.displayName)
}
},
label: { EmptyView() }
)
.pickerStyle(.radioGroup)
.labelsHidden()
.help("Select publication visibility")
makeStatusPicker()
}
Divider()
@ -116,6 +88,49 @@ struct EditorView: View {
.padding()
}
@ViewBuilder
private func makeDatePicker() -> some View {
Text("Date")
.gridColumnAlignment(.trailing)
DatePicker(
selection: $viewModel.date,
displayedComponents: [.date]
) {}
.help("Select publication date")
}
@ViewBuilder
private func makeTimePicker() -> some View {
Text("Time")
.gridColumnAlignment(.trailing)
DatePicker(
selection: $viewModel.date,
displayedComponents: [.hourAndMinute]
) {}
.help("Select publication time")
}
@ViewBuilder
private func makeStatusPicker() -> some View {
Text("Status")
.gridColumnAlignment(.trailing)
Picker(
selection: $viewModel.status,
content: {
ForEach(WeblogEntryStatus.allCases) { status in
Text(status.displayName)
}
},
label: { EmptyView() }
)
.pickerStyle(.radioGroup)
.labelsHidden()
.help("Select publication visibility")
}
@ViewBuilder
private func makeTagInputView() -> some View {
TextField("Add tag", text: $viewModel.tagInput)
@ -146,12 +161,13 @@ struct EditorView: View {
if !viewModel.suggestedTags.isEmpty {
TagListView(
tags: viewModel.suggestedTags,
helpText: { "Add existing tag '\($0)'" }
) { tag in
helpText: { "Add existing tag '\($0)'" },
action: { tag in
withAnimation {
viewModel.addTag(tag)
}
}
)
}
}
@ -170,12 +186,13 @@ struct EditorView: View {
TagListView(
tags: viewModel.tags,
style: .remove,
helpText: { "Remove tag '\($0)'" }
) { tag in
helpText: { "Remove tag '\($0)'" },
action: { tag in
withAnimation {
viewModel.removeTag(tag)
}
}
)
}
}

View file

@ -21,7 +21,7 @@ struct WeblogEntriesListView: View {
_entries = .init(viewModel.fetchDescriptor())
}
// MARK; - Public
// MARK: - Public
var body: some View {
Group {

View file

@ -83,13 +83,20 @@ struct WeblogEntryView: View {
@ViewBuilder
private func makeContextualMenu() -> some View {
makeEditEntryMenuItem()
if !viewModel.isDraft {
Divider()
makeCopyEntryURLMenuItem()
makeCopyMarkdownLinkMenuItem()
}
if !viewModel.isDraft {
Divider()
makeOpenInBrowserMenuItem()
makeShareMenuItem()
makeShareOnStatuslogMenuItem()
}
Divider()
makeDeleteEntryMenuItem()
}
@ -99,7 +106,7 @@ struct WeblogEntryView: View {
Button {
openEditor()
} label: {
Label("Edit Entry", systemImage: "link")
Label("Edit Entry", systemImage: "pencil")
}
}

View file

@ -39,6 +39,10 @@ final class WeblogEntryViewModel: Identifiable {
status.lowercased() != "live"
}
var isDraft: Bool {
status.lowercased() == "draft"
}
private let repository: any WeblogRepositoryProtocol
private let clipboardService: ClipboardServiceProtocol

View file

@ -40,6 +40,7 @@ struct WebpageEnvironment {
)
}
// swiftlint:disable function_body_length
init(
networkServiceFactory: WebpageNetworkServiceFactoryProtocol,
persistenceServiceFactory: WebpagePersistenceServiceFactoryProtocol,
@ -112,4 +113,5 @@ struct WebpageEnvironment {
)
}
}
// swiftlint:enable function_body_length
}

View file

@ -12,11 +12,11 @@
count: Int,
in container: ModelContainer
) {
for i in 0..<count {
for index in 0..<count {
let page = Webpage(
address: "otaviocc",
markdown: "Foobar \(i)",
timestamp: 123_123 * Double(i)
markdown: "Foobar \(index)",
timestamp: 123_123 * Double(index)
)
container.mainContext.insert(

View file

@ -17,8 +17,14 @@
}
}
func fetchWebpage(for address: String) async throws {}
func updateWebpage(address: String, content: String) async throws {}
func fetchWebpage(
for address: String
) async throws {}
func updateWebpage(
address: String,
content: String
) async throws {}
}
// MARK: - Public

View file

@ -37,7 +37,11 @@
// MARK: - Public
func fetchWebpage() async throws {}
func updateWebpage(address: String, content: String) async throws {}
func updateWebpage(
address: String,
content: String
) async throws {}
}
// MARK: - Public

View file

@ -3,5 +3,6 @@ import XCTest
final class WebpageTests: XCTestCase {
// swiftlint:disable:next empty_xctest_method
func testExample() throws {}
}

View file

@ -102,13 +102,21 @@ Switch seamlessly between multiple omg.lol addresses, all in one app.
You can download the latest pre-built version of Triton directly from the [GitHub Releases page](https://github.com/otaviocc/Triton/releases). Simply download the `.zip` file from the latest release, open it, and drag OMG.app to your Applications folder. This method is useful if you prefer manual installation or don't use Homebrew.
**Install via [Brew](https://brew.sh) 🤩**
**Install via [Homebrew](https://brew.sh) 🤩**
```bash
brew tap otaviocc/apps
brew install --cask triton
```
**Upgrade Triton**
If you installed Triton using Homebrew, you can upgrade to the latest version with:
```bash
brew upgrade --cask triton
```
## Thanks and Acknowledgments
This project wouldn't exist without the amazing work of several people in the omg.lol community: