Compare commits
10
Commits
f624ce6c9f
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c083b0ba5
|
||
|
|
d0b6b35bda
|
||
|
|
289e3062a1
|
||
|
|
71070daf86
|
||
|
|
f1cc8607b0
|
||
|
|
48377668b0
|
||
|
|
8db8e985a7
|
||
|
|
b8ce517b60
|
||
|
|
5de445daa5
|
||
|
|
8ca5735019
|
@@ -6,7 +6,7 @@ body:
|
|||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
value: |
|
value: |
|
||||||
Outpost is early alpha — please check the version in About (or your build's commit) is current before filing, and mention which platform (macOS only, for now) and OS version you're on.
|
Outpost is early — please check the version in About (or your build's commit) is current before filing, and mention which platform (macOS only, for now) and OS version you're on.
|
||||||
- type: input
|
- type: input
|
||||||
id: summary
|
id: summary
|
||||||
attributes:
|
attributes:
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ body:
|
|||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
value: |
|
value: |
|
||||||
Outpost is early alpha and tracking Outline's own web app for parity (see [`CLAUDE.md`](../../CLAUDE.md) for the phased build order) — a request that's "just do what web Outline does" is easier to act on than a net-new idea.
|
Outpost is early and tracking Outline's own web app for parity (see [`CLAUDE.md`](../../CLAUDE.md) for the phased build order) — a request that's "just do what web Outline does" is easier to act on than a net-new idea.
|
||||||
- type: input
|
- type: input
|
||||||
id: summary
|
id: summary
|
||||||
attributes:
|
attributes:
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Thank you for contributing. Please read this guide before opening issues or PRs.
|
Thank you for contributing. Please read this guide before opening issues or PRs.
|
||||||
|
|
||||||
Outpost is early alpha (`0.0.x`) — expect the codebase and conventions here to shift as Phase 1 (see [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md)) settles. If something in this guide is stale, flag it.
|
Outpost is early (`0.1.x`) — expect the codebase and conventions here to keep evolving as later phases (see [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md)) land. If something in this guide is stale, flag it.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
archiveVersion = 1;
|
archiveVersion = 1;
|
||||||
classes = {
|
classes = {
|
||||||
};
|
};
|
||||||
objectVersion = 110;
|
objectVersion = 77;
|
||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
@@ -402,7 +402,7 @@
|
|||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
|
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 3;
|
||||||
DEVELOPMENT_TEAM = CW6GQT9SK5;
|
DEVELOPMENT_TEAM = CW6GQT9SK5;
|
||||||
ENABLE_APP_SANDBOX = YES;
|
ENABLE_APP_SANDBOX = YES;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
@@ -424,8 +424,8 @@
|
|||||||
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||||
MACOSX_DEPLOYMENT_TARGET = 27.0;
|
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||||
MARKETING_VERSION = 0.0.4;
|
MARKETING_VERSION = 0.1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.OutpostApp;
|
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.OutpostApp;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
@@ -454,7 +454,7 @@
|
|||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
|
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 3;
|
||||||
DEVELOPMENT_TEAM = CW6GQT9SK5;
|
DEVELOPMENT_TEAM = CW6GQT9SK5;
|
||||||
ENABLE_APP_SANDBOX = YES;
|
ENABLE_APP_SANDBOX = YES;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
@@ -476,8 +476,8 @@
|
|||||||
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||||
MACOSX_DEPLOYMENT_TARGET = 27.0;
|
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||||
MARKETING_VERSION = 0.0.4;
|
MARKETING_VERSION = 0.1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.OutpostApp;
|
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.OutpostApp;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
@@ -505,7 +505,7 @@
|
|||||||
DEVELOPMENT_TEAM = CW6GQT9SK5;
|
DEVELOPMENT_TEAM = CW6GQT9SK5;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 27.0;
|
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.OutpostTests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.OutpostTests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
@@ -531,7 +531,7 @@
|
|||||||
DEVELOPMENT_TEAM = CW6GQT9SK5;
|
DEVELOPMENT_TEAM = CW6GQT9SK5;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 27.0;
|
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.OutpostTests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.OutpostTests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
@@ -556,7 +556,7 @@
|
|||||||
DEVELOPMENT_TEAM = CW6GQT9SK5;
|
DEVELOPMENT_TEAM = CW6GQT9SK5;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 27.0;
|
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.OutpostUITests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.OutpostUITests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
@@ -581,7 +581,7 @@
|
|||||||
DEVELOPMENT_TEAM = CW6GQT9SK5;
|
DEVELOPMENT_TEAM = CW6GQT9SK5;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 27.0;
|
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.OutpostUITests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.OutpostUITests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Scheme
|
||||||
|
LastUpgradeVersion = "2700"
|
||||||
|
version = "1.7">
|
||||||
|
<BuildAction
|
||||||
|
parallelizeBuildables = "YES"
|
||||||
|
buildImplicitDependencies = "YES"
|
||||||
|
buildArchitectures = "Automatic">
|
||||||
|
<BuildActionEntries>
|
||||||
|
<BuildActionEntry
|
||||||
|
buildForTesting = "YES"
|
||||||
|
buildForRunning = "YES"
|
||||||
|
buildForProfiling = "YES"
|
||||||
|
buildForArchiving = "YES"
|
||||||
|
buildForAnalyzing = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "FAF99C17302CE96100C9949F"
|
||||||
|
BuildableName = "Outpost.app"
|
||||||
|
ReferencedContainer = "container:Outpost.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildActionEntry>
|
||||||
|
</BuildActionEntries>
|
||||||
|
</BuildAction>
|
||||||
|
<TestAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
shouldAutocreateTestPlan = "YES">
|
||||||
|
<Testables>
|
||||||
|
<TestableReference
|
||||||
|
skipped = "NO"
|
||||||
|
parallelizable = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "FAF99C26302CE96200C9949F"
|
||||||
|
BuildableName = "OutpostTests.xctest"
|
||||||
|
ReferencedContainer = "container:Outpost.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</TestableReference>
|
||||||
|
<TestableReference
|
||||||
|
skipped = "NO"
|
||||||
|
parallelizable = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "FAF99C30302CE96200C9949F"
|
||||||
|
BuildableName = "OutpostUITests.xctest"
|
||||||
|
ReferencedContainer = "container:Outpost.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</TestableReference>
|
||||||
|
</Testables>
|
||||||
|
</TestAction>
|
||||||
|
<LaunchAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
launchStyle = "0"
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
ignoresPersistentStateOnLaunch = "NO"
|
||||||
|
debugDocumentVersioning = "YES"
|
||||||
|
debugServiceExtension = "internal"
|
||||||
|
allowLocationSimulation = "YES"
|
||||||
|
queueDebuggingEnabled = "No">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "FAF99C17302CE96100C9949F"
|
||||||
|
BuildableName = "Outpost.app"
|
||||||
|
ReferencedContainer = "container:Outpost.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
<StoreKitConfigurationFileReference
|
||||||
|
identifier = "../../Outpost/Configuration.storekit">
|
||||||
|
</StoreKitConfigurationFileReference>
|
||||||
|
</LaunchAction>
|
||||||
|
<ProfileAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
savedToolIdentifier = ""
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
debugDocumentVersioning = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "FAF99C17302CE96100C9949F"
|
||||||
|
BuildableName = "Outpost.app"
|
||||||
|
ReferencedContainer = "container:Outpost.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
</ProfileAction>
|
||||||
|
<AnalyzeAction
|
||||||
|
buildConfiguration = "Debug">
|
||||||
|
</AnalyzeAction>
|
||||||
|
<ArchiveAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
revealArchiveInOrganizer = "YES">
|
||||||
|
</ArchiveAction>
|
||||||
|
</Scheme>
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
{
|
||||||
|
"identifier" : "12E4A6D1-6B8A-4C2E-9F3A-0D1B2C3E4F5A",
|
||||||
|
"nonRenewingSubscriptions" : [],
|
||||||
|
"products" : [
|
||||||
|
{
|
||||||
|
"displayPrice" : "0.99",
|
||||||
|
"familyShareable" : false,
|
||||||
|
"internalID" : "992ABE97-F5C4-4F80-8FB0-382BDF47DAB6",
|
||||||
|
"localizations" : [
|
||||||
|
{
|
||||||
|
"description" : "A small tip to support Outpost's development.",
|
||||||
|
"displayName" : "Small Tip",
|
||||||
|
"locale" : "en_US"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"productID" : "com.psmattas.OutpostApp.tip.small",
|
||||||
|
"referenceName" : "Small Tip",
|
||||||
|
"type" : "Consumable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"displayPrice" : "2.99",
|
||||||
|
"familyShareable" : false,
|
||||||
|
"internalID" : "316DEB73-6BA3-4F6B-BD54-D17A1CE61938",
|
||||||
|
"localizations" : [
|
||||||
|
{
|
||||||
|
"description" : "A medium tip to support Outpost's development.",
|
||||||
|
"displayName" : "Medium Tip",
|
||||||
|
"locale" : "en_US"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"productID" : "com.psmattas.OutpostApp.tip.medium",
|
||||||
|
"referenceName" : "Medium Tip",
|
||||||
|
"type" : "Consumable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"displayPrice" : "4.99",
|
||||||
|
"familyShareable" : false,
|
||||||
|
"internalID" : "37936F94-AC21-4A39-BC85-CAE6609E170D",
|
||||||
|
"localizations" : [
|
||||||
|
{
|
||||||
|
"description" : "A large tip to support Outpost's development.",
|
||||||
|
"displayName" : "Large Tip",
|
||||||
|
"locale" : "en_US"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"productID" : "com.psmattas.OutpostApp.tip.large",
|
||||||
|
"referenceName" : "Large Tip",
|
||||||
|
"type" : "Consumable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"displayPrice" : "9.99",
|
||||||
|
"familyShareable" : false,
|
||||||
|
"internalID" : "E072C1AC-2719-483E-8A54-F4924808B724",
|
||||||
|
"localizations" : [
|
||||||
|
{
|
||||||
|
"description" : "A generous tip to support Outpost's development.",
|
||||||
|
"displayName" : "Generous Tip",
|
||||||
|
"locale" : "en_US"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"productID" : "com.psmattas.OutpostApp.tip.generous",
|
||||||
|
"referenceName" : "Generous Tip",
|
||||||
|
"type" : "Consumable"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"settings" : {
|
||||||
|
"_askToBuyEnabled" : false
|
||||||
|
},
|
||||||
|
"subscriptionGroups" : [],
|
||||||
|
"version" : {
|
||||||
|
"major" : 3,
|
||||||
|
"minor" : 0
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,6 +48,11 @@ struct AboutInfoView: View {
|
|||||||
}
|
}
|
||||||
.font(.callout)
|
.font(.callout)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
.frame(maxWidth: 240)
|
||||||
|
|
||||||
|
TipJarView()
|
||||||
|
|
||||||
Text("© \(copyrightYear) Puranjay Savar Mattas")
|
Text("© \(copyrightYear) Puranjay Savar Mattas")
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundStyle(.tertiary)
|
.foregroundStyle(.tertiary)
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
#if os(macOS)
|
||||||
|
import SwiftUI
|
||||||
|
import StoreKit
|
||||||
|
|
||||||
|
/// One button per consumable tip tier — no "restore purchases" (nothing to
|
||||||
|
/// restore, consumables aren't entitlements) and no manual retry: a failed
|
||||||
|
/// load just shows a message, tapping a tier again re-attempts naturally.
|
||||||
|
struct TipJarView: View {
|
||||||
|
@State private var store = TipJarStore()
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
Text("Support Outpost")
|
||||||
|
.font(.callout.weight(.semibold))
|
||||||
|
|
||||||
|
if store.isLoading && store.products.isEmpty {
|
||||||
|
ProgressView()
|
||||||
|
.controlSize(.small)
|
||||||
|
} else if !store.products.isEmpty {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ForEach(store.products) { product in
|
||||||
|
tipButton(for: product)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch store.purchaseState {
|
||||||
|
case .thankYou:
|
||||||
|
Label("Thank you!", systemImage: "heart.fill")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.pink)
|
||||||
|
case .failed(let message):
|
||||||
|
Text(message)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
case .idle, .purchasing:
|
||||||
|
EmptyView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task { await store.loadProductsIfNeeded() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func tipButton(for product: Product) -> some View {
|
||||||
|
Button {
|
||||||
|
Task { await store.purchase(product) }
|
||||||
|
} label: {
|
||||||
|
VStack(spacing: 2) {
|
||||||
|
if isPurchasing(product) {
|
||||||
|
ProgressView()
|
||||||
|
.controlSize(.small)
|
||||||
|
} else {
|
||||||
|
Text(product.displayPrice)
|
||||||
|
.font(.callout.weight(.semibold))
|
||||||
|
}
|
||||||
|
Text(product.displayName)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.frame(minWidth: 64)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.disabled(isAnyPurchaseInFlight)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isPurchasing(_ product: Product) -> Bool {
|
||||||
|
store.purchaseState == .purchasing(product.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var isAnyPurchaseInFlight: Bool {
|
||||||
|
if case .purchasing = store.purchaseState { return true }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import StoreKit
|
||||||
import OutlineKit
|
import OutlineKit
|
||||||
|
|
||||||
/// Uses a plain `Button` + `.popover` rather than `Menu`. A `Menu` whose label
|
/// Uses a plain `Button` + `.popover` rather than `Menu`. A `Menu` whose label
|
||||||
@@ -9,20 +10,13 @@ import OutlineKit
|
|||||||
struct AccountFooter: View {
|
struct AccountFooter: View {
|
||||||
@Environment(SessionStore.self) private var session
|
@Environment(SessionStore.self) private var session
|
||||||
@Environment(AppNavigation.self) private var navigation
|
@Environment(AppNavigation.self) private var navigation
|
||||||
@Environment(\.openURL) private var openURL
|
@Environment(\.requestReview) private var requestReview
|
||||||
@AppStorage("outpost.appearance") private var appearance: AppAppearance = .system
|
@AppStorage("outpost.appearance") private var appearance: AppAppearance = .system
|
||||||
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
|
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
|
||||||
@State private var isMenuPresented = false
|
@State private var isMenuPresented = false
|
||||||
@State private var isShowingLogoutConfirmation = false
|
@State private var isShowingLogoutConfirmation = false
|
||||||
@State private var isShowingProfile = false
|
@State private var isShowingProfile = false
|
||||||
|
|
||||||
private let repositoryURL = URL(string: "https://git.psmattas.com/psmattas/Outpost")!
|
|
||||||
private let issuesURL = URL(string: "https://git.psmattas.com/psmattas/Outpost/issues")!
|
|
||||||
|
|
||||||
private var apiDocumentationURL: URL? {
|
|
||||||
session.serverURL?.appendingPathComponent("developers")
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Button {
|
Button {
|
||||||
isMenuPresented = true
|
isMenuPresented = true
|
||||||
@@ -62,16 +56,10 @@ struct AccountFooter: View {
|
|||||||
|
|
||||||
private var menuContent: some View {
|
private var menuContent: some View {
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
menuItem("Documentation") { openURL(repositoryURL) }
|
// Apple's own review/feedback prompt — there's no separate
|
||||||
if let apiDocumentationURL {
|
// native channel for "bug" vs. "feedback", so one button covers
|
||||||
menuItem("API Documentation") { openURL(apiDocumentationURL) }
|
// both.
|
||||||
}
|
menuItem("Leave Us Feedback") { requestReview() }
|
||||||
menuItem("Changelog") { openURL(repositoryURL) }
|
|
||||||
|
|
||||||
Divider()
|
|
||||||
|
|
||||||
menuItem("Send Us Feedback") { openURL(issuesURL) }
|
|
||||||
menuItem("Report a Bug") { openURL(issuesURL) }
|
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
@@ -101,6 +89,14 @@ struct AccountFooter: View {
|
|||||||
// as "Invalid attempt to open a new transaction during CA
|
// as "Invalid attempt to open a new transaction during CA
|
||||||
// commit") — letting the popover's dismissal finish first avoids it.
|
// commit") — letting the popover's dismissal finish first avoids it.
|
||||||
menuItem("Settings…") { Task { @MainActor in navigation.isShowingSettings = true } }
|
menuItem("Settings…") { Task { @MainActor in navigation.isShowingSettings = true } }
|
||||||
|
// Same deferred-Task reasoning as Settings… above — this also
|
||||||
|
// sets isShowingSettings synchronously.
|
||||||
|
menuItem("Support Outpost") {
|
||||||
|
Task { @MainActor in
|
||||||
|
navigation.selectedSettingsSection = .about
|
||||||
|
navigation.isShowingSettings = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
|
|||||||
@@ -74,10 +74,12 @@ struct AvatarCropperView: View {
|
|||||||
Button("Cancel", role: .cancel, action: onCancel)
|
Button("Cancel", role: .cancel, action: onCancel)
|
||||||
Spacer()
|
Spacer()
|
||||||
Button("Use Photo") {
|
Button("Use Photo") {
|
||||||
if let data = renderFinalImage() {
|
Task {
|
||||||
|
if let data = await renderFinalImage() {
|
||||||
onConfirm(data)
|
onConfirm(data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
.buttonStyle(.borderedProminent)
|
.buttonStyle(.borderedProminent)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -100,15 +102,26 @@ struct AvatarCropperView: View {
|
|||||||
.clipped()
|
.clipped()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `ImageRenderer` itself has to run on the main actor (it captures live
|
||||||
|
/// SwiftUI view state), but JPEG compression on the bitmap it produces
|
||||||
|
/// is pure CPU work with no SwiftUI dependency left — hopping off for
|
||||||
|
/// just that part avoids a visible hitch on tapping "Use Photo".
|
||||||
|
/// `tiffRepresentation` (plain `Data`, unlike `NSImage` itself) is what
|
||||||
|
/// actually crosses the actor boundary; mirrors `NSImage.jpegData(
|
||||||
|
/// compressionQuality:)`'s own logic rather than calling it directly, so
|
||||||
|
/// crossing doesn't require handing a non-Sendable `NSImage` to a
|
||||||
|
/// detached task.
|
||||||
@MainActor
|
@MainActor
|
||||||
private func renderFinalImage() -> Data? {
|
private func renderFinalImage() async -> Data? {
|
||||||
let content = avatarContent
|
let content = avatarContent
|
||||||
.clipShape(Circle())
|
.clipShape(Circle())
|
||||||
.frame(width: diameter, height: diameter)
|
.frame(width: diameter, height: diameter)
|
||||||
let renderer = ImageRenderer(content: content)
|
let renderer = ImageRenderer(content: content)
|
||||||
renderer.scale = 2 // @2x so it isn't a blurry 320px avatar on Retina displays
|
renderer.scale = 2 // @2x so it isn't a blurry 320px avatar on Retina displays
|
||||||
guard let nsImage = renderer.nsImage else { return nil }
|
guard let tiffData = renderer.nsImage?.tiffRepresentation else { return nil }
|
||||||
return nsImage.jpegData(compressionQuality: 0.9)
|
return await Task.detached(priority: .userInitiated) {
|
||||||
|
NSBitmapImageRep(data: tiffData)?.representation(using: .jpeg, properties: [.compressionFactor: 0.9])
|
||||||
|
}.value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -30,8 +30,15 @@ struct CollectionDocumentsOutline: View {
|
|||||||
/// every document in the tree.
|
/// every document in the tree.
|
||||||
@State private var pinsByDocumentID: [String: OutlinePin] = [:]
|
@State private var pinsByDocumentID: [String: OutlinePin] = [:]
|
||||||
|
|
||||||
private var tree: [DocumentNode] {
|
/// Recomputed only when `viewModel.documents`/`sortOption` actually change
|
||||||
buildDocumentTree(from: viewModel.documents, sortedBy: sortOption)
|
/// (below) instead of being a computed property — this rebuilt the whole
|
||||||
|
/// dictionary-grouped, recursively-sorted tree on every `body` evaluation,
|
||||||
|
/// including renders triggered by unrelated state (selection, hover,
|
||||||
|
/// pins) that don't change the tree's shape at all.
|
||||||
|
@State private var tree: [DocumentNode] = []
|
||||||
|
|
||||||
|
private func rebuildTree() {
|
||||||
|
tree = buildDocumentTree(from: viewModel.documents, sortedBy: sortOption)
|
||||||
}
|
}
|
||||||
|
|
||||||
init(
|
init(
|
||||||
@@ -86,7 +93,10 @@ struct CollectionDocumentsOutline: View {
|
|||||||
.task(id: "\(refreshToken)-\(externalRefreshToken)") {
|
.task(id: "\(refreshToken)-\(externalRefreshToken)") {
|
||||||
await viewModel.load()
|
await viewModel.load()
|
||||||
await loadPins()
|
await loadPins()
|
||||||
|
rebuildTree()
|
||||||
}
|
}
|
||||||
|
.onChange(of: viewModel.documents) { rebuildTree() }
|
||||||
|
.onChange(of: sortOption) { rebuildTree() }
|
||||||
}
|
}
|
||||||
|
|
||||||
private func loadPins() async {
|
private func loadPins() async {
|
||||||
|
|||||||
@@ -34,8 +34,15 @@ struct CollectionOverviewView: View {
|
|||||||
self.onOpenDocument = onOpenDocument
|
self.onOpenDocument = onOpenDocument
|
||||||
}
|
}
|
||||||
|
|
||||||
private var sortedDocuments: [OutlineDocument] {
|
/// Recomputed only when `viewModel.documents`/`selectedTab` actually
|
||||||
selectedTab.sorted(viewModel.documents)
|
/// change (below) instead of being a computed property re-sorted on
|
||||||
|
/// every render — capped at 100 documents per collection page, so lower
|
||||||
|
/// blast radius than the sidebar/command-palette versions of this same
|
||||||
|
/// pattern, but the same fix.
|
||||||
|
@State private var sortedDocuments: [OutlineDocument] = []
|
||||||
|
|
||||||
|
private func resortDocuments() {
|
||||||
|
sortedDocuments = selectedTab.sorted(viewModel.documents)
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -94,6 +101,8 @@ struct CollectionOverviewView: View {
|
|||||||
await viewModel.checkForRemoteChanges()
|
await viewModel.checkForRemoteChanges()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.onChange(of: viewModel.documents) { resortDocuments() }
|
||||||
|
.onChange(of: selectedTab) { resortDocuments() }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Spans the full window width, centered, directly under the toolbar —
|
// Spans the full window width, centered, directly under the toolbar —
|
||||||
|
|||||||
@@ -41,22 +41,31 @@ struct CommandPaletteView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var results: [Result] {
|
/// Recomputed only when `query`/`collections`/`documents` actually change
|
||||||
|
/// (below) instead of being a computed property — Full Workspace mode's
|
||||||
|
/// index can be large (every document in the local cache, sub-documents
|
||||||
|
/// included), and this was re-scanning + re-sorting the entire thing on
|
||||||
|
/// every render, including ones triggered by unrelated state like
|
||||||
|
/// `selectedIndex` changing as arrow keys move the selection.
|
||||||
|
@State private var results: [Result] = []
|
||||||
|
|
||||||
|
private func recomputeResults() {
|
||||||
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !trimmed.isEmpty else {
|
guard !trimmed.isEmpty else {
|
||||||
// No query yet: surface collections first, then the most
|
// No query yet: surface collections first, then the most
|
||||||
// recent/full-workspace documents as-is, capped so the panel
|
// recent/full-workspace documents as-is, capped so the panel
|
||||||
// doesn't dump the entire workspace with nothing typed.
|
// doesn't dump the entire workspace with nothing typed.
|
||||||
return (collections.map(Result.collection) + documents.map(Result.document))
|
results = (collections.map(Result.collection) + documents.map(Result.document))
|
||||||
.prefix(20)
|
.prefix(20)
|
||||||
.map { $0 }
|
.map { $0 }
|
||||||
|
return
|
||||||
}
|
}
|
||||||
let scored: [(Result, Int)] = collections.compactMap { collection in
|
let scored: [(Result, Int)] = collections.compactMap { collection in
|
||||||
matchScore(collection.name, query: trimmed).map { (Result.collection(collection), $0) }
|
matchScore(collection.name, query: trimmed).map { (Result.collection(collection), $0) }
|
||||||
} + documents.compactMap { document in
|
} + documents.compactMap { document in
|
||||||
matchScore(document.title, query: trimmed).map { (Result.document(document), $0) }
|
matchScore(document.title, query: trimmed).map { (Result.document(document), $0) }
|
||||||
}
|
}
|
||||||
return scored.sorted { $0.1 < $1.1 }.prefix(30).map(\.0)
|
results = scored.sorted { $0.1 < $1.1 }.prefix(30).map(\.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Lower is better — exact match, then prefix match, then earliest
|
/// Lower is better — exact match, then prefix match, then earliest
|
||||||
@@ -100,7 +109,10 @@ struct CommandPaletteView: View {
|
|||||||
.textFieldStyle(.plain)
|
.textFieldStyle(.plain)
|
||||||
.font(.title3)
|
.font(.title3)
|
||||||
.focused($isSearchFieldFocused)
|
.focused($isSearchFieldFocused)
|
||||||
.onChange(of: query) { selectedIndex = 0 }
|
.onChange(of: query) {
|
||||||
|
selectedIndex = 0
|
||||||
|
recomputeResults()
|
||||||
|
}
|
||||||
.onSubmit { selectCurrent() }
|
.onSubmit { selectCurrent() }
|
||||||
// Attached directly on the field itself, not an
|
// Attached directly on the field itself, not an
|
||||||
// ancestor — confirmed live that .onKeyPress on the
|
// ancestor — confirmed live that .onKeyPress on the
|
||||||
@@ -169,6 +181,8 @@ struct CommandPaletteView: View {
|
|||||||
isSearchFieldFocused = true
|
isSearchFieldFocused = true
|
||||||
await loadResults()
|
await loadResults()
|
||||||
}
|
}
|
||||||
|
.onChange(of: collections) { recomputeResults() }
|
||||||
|
.onChange(of: documents) { recomputeResults() }
|
||||||
}
|
}
|
||||||
|
|
||||||
private func resultRow(_ result: Result, isSelected: Bool) -> some View {
|
private func resultRow(_ result: Result, isSelected: Bool) -> some View {
|
||||||
|
|||||||
@@ -22,9 +22,26 @@ struct DocumentSearchSheet: View {
|
|||||||
@State private var currentMatchIndex = 0
|
@State private var currentMatchIndex = 0
|
||||||
@FocusState private var isSearchFieldFocused: Bool
|
@FocusState private var isSearchFieldFocused: Bool
|
||||||
|
|
||||||
private var matchingLineIndices: [Int] {
|
/// Recomputed only when `query`/`lines` actually change (`recomputeMatches()`)
|
||||||
guard !query.isEmpty else { return [] }
|
/// instead of being a computed property — this used to re-scan the whole
|
||||||
return lines.indices.filter { lines[$0].localizedCaseInsensitiveContains(query) }
|
/// document on every access, and it's read multiple times per row
|
||||||
|
/// (`isCurrentMatch`, the highlight check) on every SwiftUI re-render, so a
|
||||||
|
/// large document turned into an O(n²) case-insensitive scan per frame.
|
||||||
|
@State private var matchingLineIndices: [Int] = []
|
||||||
|
/// O(1) membership for the per-row highlight check below — `matchingLineIndices`
|
||||||
|
/// stays an ordered array (needed for `currentMatchIndex`/stepping), this is
|
||||||
|
/// just a parallel lookup so a common search term with many matches doesn't
|
||||||
|
/// make every row's highlight check an O(k) linear scan.
|
||||||
|
@State private var matchingLineIndexSet: Set<Int> = []
|
||||||
|
|
||||||
|
private func recomputeMatches() {
|
||||||
|
guard !query.isEmpty else {
|
||||||
|
matchingLineIndices = []
|
||||||
|
matchingLineIndexSet = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
matchingLineIndices = lines.indices.filter { lines[$0].localizedCaseInsensitiveContains(query) }
|
||||||
|
matchingLineIndexSet = Set(matchingLineIndices)
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -35,7 +52,10 @@ struct DocumentSearchSheet: View {
|
|||||||
TextField("Search in \"\(document.title.isEmpty ? "Untitled" : document.title)\"", text: $query)
|
TextField("Search in \"\(document.title.isEmpty ? "Untitled" : document.title)\"", text: $query)
|
||||||
.textFieldStyle(.plain)
|
.textFieldStyle(.plain)
|
||||||
.focused($isSearchFieldFocused)
|
.focused($isSearchFieldFocused)
|
||||||
.onChange(of: query) { currentMatchIndex = 0 }
|
.onChange(of: query) {
|
||||||
|
currentMatchIndex = 0
|
||||||
|
recomputeMatches()
|
||||||
|
}
|
||||||
|
|
||||||
if !matchingLineIndices.isEmpty {
|
if !matchingLineIndices.isEmpty {
|
||||||
Text("\(currentMatchIndex + 1) of \(matchingLineIndices.count)")
|
Text("\(currentMatchIndex + 1) of \(matchingLineIndices.count)")
|
||||||
@@ -83,7 +103,7 @@ struct DocumentSearchSheet: View {
|
|||||||
.padding(.horizontal, 4)
|
.padding(.horizontal, 4)
|
||||||
.background(
|
.background(
|
||||||
isCurrentMatch(index) ? Color.yellow.opacity(0.4)
|
isCurrentMatch(index) ? Color.yellow.opacity(0.4)
|
||||||
: matchingLineIndices.contains(index) ? Color.yellow.opacity(0.15)
|
: matchingLineIndexSet.contains(index) ? Color.yellow.opacity(0.15)
|
||||||
: Color.clear
|
: Color.clear
|
||||||
)
|
)
|
||||||
.id(index)
|
.id(index)
|
||||||
@@ -131,6 +151,7 @@ struct DocumentSearchSheet: View {
|
|||||||
do {
|
do {
|
||||||
let text = try await apiClient.documentInfo(id: document.id).text
|
let text = try await apiClient.documentInfo(id: document.id).text
|
||||||
lines = text.components(separatedBy: "\n")
|
lines = text.components(separatedBy: "\n")
|
||||||
|
recomputeMatches()
|
||||||
} catch {
|
} catch {
|
||||||
errorMessage = outlineErrorMessage(error, fallback: "Couldn't load this document.")
|
errorMessage = outlineErrorMessage(error, fallback: "Couldn't load this document.")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,16 +83,25 @@ final class HomeViewModel {
|
|||||||
/// `pins.list` only returns pin records, not the documents themselves —
|
/// `pins.list` only returns pin records, not the documents themselves —
|
||||||
/// fetches each pinned document individually. Pins are a small curated
|
/// fetches each pinned document individually. Pins are a small curated
|
||||||
/// set (unlike a full collection tree), so the N+1 here is acceptable
|
/// set (unlike a full collection tree), so the N+1 here is acceptable
|
||||||
/// where it wouldn't be in the sidebar.
|
/// where it wouldn't be in the sidebar — but they're fetched concurrently
|
||||||
|
/// (a `TaskGroup`, not a serial loop) so latency doesn't scale with pin
|
||||||
|
/// count; `documentInfo` already goes through `CachingOutlineAPIClient`'s
|
||||||
|
/// own cached-read/retry path either way.
|
||||||
private func fetchPinnedThrowing() async throws -> [OutlineDocument] {
|
private func fetchPinnedThrowing() async throws -> [OutlineDocument] {
|
||||||
let pins = try await RetryPolicy.withRetry { try await apiClient.listPins(ListPinsRequest(collectionId: nil)) }
|
let pins = try await RetryPolicy.withRetry { try await apiClient.listPins(ListPinsRequest(collectionId: nil)) }
|
||||||
var documents: [OutlineDocument] = []
|
let client = apiClient
|
||||||
|
let documentsByID: [String: OutlineDocument] = await withTaskGroup(of: (String, OutlineDocument?).self) { group in
|
||||||
for pin in pins {
|
for pin in pins {
|
||||||
if let document = try? await apiClient.documentInfo(id: pin.documentId) {
|
group.addTask { (pin.documentId, try? await client.documentInfo(id: pin.documentId)) }
|
||||||
documents.append(document)
|
|
||||||
}
|
}
|
||||||
|
var result: [String: OutlineDocument] = [:]
|
||||||
|
for await (id, document) in group {
|
||||||
|
if let document { result[id] = document }
|
||||||
}
|
}
|
||||||
return documents
|
return result
|
||||||
|
}
|
||||||
|
// Preserve pins.list's own order rather than task-completion order.
|
||||||
|
return pins.compactMap { documentsByID[$0.documentId] }
|
||||||
}
|
}
|
||||||
|
|
||||||
private func fetch(tab: HomeTab) async throws -> [OutlineDocument] {
|
private func fetch(tab: HomeTab) async throws -> [OutlineDocument] {
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ final class OutlineImageProvider: EmbeddedImageProvider, @unchecked Sendable {
|
|||||||
guard !alreadyLoading else { return }
|
guard !alreadyLoading else { return }
|
||||||
|
|
||||||
Task {
|
Task {
|
||||||
defer { lock.withLock { inFlight.remove(url) } }
|
defer { lock.withLock { _ = inFlight.remove(url) } }
|
||||||
do {
|
do {
|
||||||
let data: Data
|
let data: Data
|
||||||
if url.hasPrefix("http://") || url.hasPrefix("https://"), let externalURL = URL(string: url) {
|
if url.hasPrefix("http://") || url.hasPrefix("https://"), let externalURL = URL(string: url) {
|
||||||
|
|||||||
@@ -7,24 +7,26 @@ import Foundation
|
|||||||
enum OutpostVersion {
|
enum OutpostVersion {
|
||||||
/// Bumped alongside `MARKETING_VERSION` in the Xcode project — kept out
|
/// Bumped alongside `MARKETING_VERSION` in the Xcode project — kept out
|
||||||
/// of the bundle version itself since `CFBundleShortVersionString` is
|
/// of the bundle version itself since `CFBundleShortVersionString` is
|
||||||
/// expected to stay a plain dotted-numeric string, not `0.0.1-ALPHA`.
|
/// expected to stay a plain dotted-numeric string, not `0.1.0-ALPHA`.
|
||||||
static let releaseStage = "ALPHA"
|
/// Empty since the App Store release (no more alpha/beta suffix) —
|
||||||
|
/// `displayString`/`fullVersionString` just show the plain version now.
|
||||||
|
static let releaseStage = ""
|
||||||
|
|
||||||
static var shortVersion: String {
|
static var shortVersion: String {
|
||||||
Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.0.1"
|
Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.1.0"
|
||||||
}
|
}
|
||||||
|
|
||||||
static var buildNumber: String {
|
static var buildNumber: String {
|
||||||
Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "1"
|
Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "3"
|
||||||
}
|
}
|
||||||
|
|
||||||
/// e.g. `"0.0.3-ALPHA"` — for compact display (sidebar footer).
|
/// e.g. `"0.1.0"` — for compact display (sidebar footer).
|
||||||
static var displayString: String {
|
static var displayString: String {
|
||||||
let stageSuffix = releaseStage.isEmpty ? "" : "-\(releaseStage)"
|
let stageSuffix = releaseStage.isEmpty ? "" : "-\(releaseStage)"
|
||||||
return "\(shortVersion)\(stageSuffix)"
|
return "\(shortVersion)\(stageSuffix)"
|
||||||
}
|
}
|
||||||
|
|
||||||
/// e.g. `"Version 0.0.3-ALPHA (1)"` — for the About page.
|
/// e.g. `"Version 0.1.0 (3)"` — for the About page.
|
||||||
static var fullVersionString: String {
|
static var fullVersionString: String {
|
||||||
"Version \(displayString) (\(buildNumber))"
|
"Version \(displayString) (\(buildNumber))"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import StoreKit
|
||||||
|
import Observation
|
||||||
|
|
||||||
|
/// Backs the tip jar in Settings → About. Consumables only — a tip doesn't
|
||||||
|
/// unlock anything, so there's no entitlement to persist or restore, and
|
||||||
|
/// finishing the transaction immediately (rather than checking
|
||||||
|
/// `Transaction.currentEntitlements` on launch, the way a real purchase
|
||||||
|
/// would need to) is correct here.
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
final class TipJarStore {
|
||||||
|
enum PurchaseState: Equatable {
|
||||||
|
case idle
|
||||||
|
case purchasing(String)
|
||||||
|
case thankYou(String)
|
||||||
|
case failed(String)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Must match the consumable In-App Purchase products created in App
|
||||||
|
/// Store Connect for this app exactly, including the bundle id prefix.
|
||||||
|
static let productIDs = [
|
||||||
|
"com.psmattas.OutpostApp.tip.small",
|
||||||
|
"com.psmattas.OutpostApp.tip.medium",
|
||||||
|
"com.psmattas.OutpostApp.tip.large",
|
||||||
|
"com.psmattas.OutpostApp.tip.generous"
|
||||||
|
]
|
||||||
|
|
||||||
|
private(set) var products: [Product] = []
|
||||||
|
private(set) var isLoading = false
|
||||||
|
var purchaseState: PurchaseState = .idle
|
||||||
|
|
||||||
|
/// Tip options don't change during a session — no reason to refetch
|
||||||
|
/// every time the About screen appears.
|
||||||
|
func loadProductsIfNeeded() async {
|
||||||
|
guard products.isEmpty, !isLoading else { return }
|
||||||
|
isLoading = true
|
||||||
|
defer { isLoading = false }
|
||||||
|
do {
|
||||||
|
let fetched = try await Product.products(for: Self.productIDs)
|
||||||
|
// Keep the order defined above (small -> generous), not
|
||||||
|
// whatever order the App Store happens to return them in.
|
||||||
|
products = Self.productIDs.compactMap { id in fetched.first { $0.id == id } }
|
||||||
|
if products.isEmpty {
|
||||||
|
purchaseState = .failed("Tip options aren't available right now.")
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
purchaseState = .failed("Couldn't load tip options. Check your connection and try again.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func purchase(_ product: Product) async {
|
||||||
|
purchaseState = .purchasing(product.id)
|
||||||
|
do {
|
||||||
|
switch try await product.purchase() {
|
||||||
|
case .success(let verification):
|
||||||
|
guard case .verified(let transaction) = verification else {
|
||||||
|
purchaseState = .failed("Couldn't verify this purchase.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await transaction.finish()
|
||||||
|
purchaseState = .thankYou(product.id)
|
||||||
|
case .userCancelled, .pending:
|
||||||
|
purchaseState = .idle
|
||||||
|
@unknown default:
|
||||||
|
purchaseState = .idle
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
purchaseState = .failed("Something went wrong completing the purchase.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,14 +5,14 @@
|
|||||||
<h1 align="center">Outpost</h1>
|
<h1 align="center">Outpost</h1>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://testflight.apple.com/join/y1mYcYAM">
|
<a href="https://apps.apple.com/us/app/outpost-for-outline/id6802736230">
|
||||||
<img src="https://img.shields.io/badge/Download-TestFlight-0D96F6?style=for-the-badge&logo=apple&logoColor=white" alt="Download on TestFlight">
|
<img src="docs/assets/mac-app-store-badge.svg" height="40" alt="Download on the Mac App Store">
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
A native Apple ecosystem client for [Outline](https://github.com/outline/outline) — built for iOS, iPadOS, and macOS from a single SwiftUI codebase, aiming for full editing parity with Outline's web app, including realtime collaborative editing.
|
A native Apple ecosystem client for [Outline](https://github.com/outline/outline) — built for iOS, iPadOS, and macOS from a single SwiftUI codebase, aiming for full editing parity with Outline's web app, including realtime collaborative editing.
|
||||||
|
|
||||||
> **Early alpha — macOS only for now.** Expect missing features and rough edges. iOS/iPadOS support is planned but not in the current build. See the [releases page](https://git.psmattas.com/psmattas/Outpost/releases) for changelogs, and [open an issue](https://git.psmattas.com/psmattas/Outpost/issues) if you hit anything.
|
> **macOS only for now.** Expect missing features and rough edges. iOS/iPadOS support is planned but not in the current build. See the [releases page](https://git.psmattas.com/psmattas/Outpost/releases) for changelogs, and [open an issue](https://git.psmattas.com/psmattas/Outpost/issues) if you hit anything.
|
||||||
|
|
||||||
## Why
|
## Why
|
||||||
|
|
||||||
@@ -21,7 +21,7 @@ Outline's web app is great, but there's no native Apple client with full editing
|
|||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- Xcode 27+ (currently developed against an Xcode 27 beta — this is a hard minimum, not a suggestion)
|
- Xcode 27+ (currently developed against an Xcode 27 beta — this is a hard minimum, not a suggestion)
|
||||||
- macOS 27+. iOS/iPadOS support is planned but not in the current build (see the alpha note above) — same 27+ minimum will apply once it lands
|
- macOS 14+ to run the app. iOS/iPadOS support is planned but not in the current build (see the note above)
|
||||||
- A self-hosted (or hosted) Outline instance with API access
|
- A self-hosted (or hosted) Outline instance with API access
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|||||||
+3
-3
@@ -18,9 +18,9 @@ within 14 days depending on severity.
|
|||||||
|
|
||||||
## Supported Versions
|
## Supported Versions
|
||||||
|
|
||||||
Outpost is in early alpha (`0.0.x`) — there's no stable release line
|
Outpost is early (`0.1.x`) — there's no stable release line yet. Only
|
||||||
yet. Only the most recent tagged release receives fixes; please make
|
the most recent tagged release receives fixes; please make sure
|
||||||
sure you're on the latest alpha before reporting.
|
you're on the latest release before reporting.
|
||||||
|
|
||||||
| Version | Supported |
|
| Version | Supported |
|
||||||
| :--- | :---: |
|
| :--- | :---: |
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<svg id="livetype" xmlns="http://www.w3.org/2000/svg" width="156.10054" height="40" viewBox="0 0 156.10054 40">
|
||||||
|
<title>Download_on_the_Mac_App_Store_Badge_US-UK_RGB_blk_092917</title>
|
||||||
|
<g>
|
||||||
|
<g>
|
||||||
|
<g>
|
||||||
|
<path d="M146.57123,0H9.53468c-.3667,0-.729,0-1.09473.002-.30615.002-.60986.00781-.91895.0127A13.21476,13.21476,0,0,0,5.5171.19141a6.66509,6.66509,0,0,0-1.90088.627A6.4378,6.4378,0,0,0,1.99757,1.99707,6.25844,6.25844,0,0,0,.81935,3.61816a6.60119,6.60119,0,0,0-.625,1.90332,12.993,12.993,0,0,0-.1792,2.002C.00587,7.83008.00489,8.1377,0,8.44434V31.5586c.00489.3105.00587.6113.01514.9219a12.99232,12.99232,0,0,0,.1792,2.0019,6.58756,6.58756,0,0,0,.625,1.9043A6.20778,6.20778,0,0,0,1.99757,38.001a6.27446,6.27446,0,0,0,1.61865,1.1787,6.70082,6.70082,0,0,0,1.90088.6308,13.45514,13.45514,0,0,0,2.0039.1768c.30909.0068.6128.0107.91895.0107C8.80567,40,9.168,40,9.53468,40H146.57123c.3594,0,.7246,0,1.084-.002.3047,0,.6172-.0039.9219-.0107a13.279,13.279,0,0,0,2-.1768,6.80432,6.80432,0,0,0,1.9082-.6308,6.27742,6.27742,0,0,0,1.6172-1.1787,6.39482,6.39482,0,0,0,1.1816-1.6143,6.60413,6.60413,0,0,0,.6191-1.9043,13.50643,13.50643,0,0,0,.1856-2.0019c.0039-.3106.0039-.6114.0039-.9219.0078-.3633.0078-.7246.0078-1.0938V9.53613c0-.36621,0-.72949-.0078-1.09179,0-.30664,0-.61426-.0039-.9209a13.5071,13.5071,0,0,0-.1856-2.002,6.6177,6.6177,0,0,0-.6191-1.90332,6.46619,6.46619,0,0,0-2.7988-2.7998,6.76754,6.76754,0,0,0-1.9082-.627,13.04394,13.04394,0,0,0-2-.17676c-.3047-.00488-.6172-.01074-.9219-.01269-.3594-.002-.7246-.002-1.084-.002Z" style="fill: #a6a6a6"/>
|
||||||
|
<path d="M8.44483,39.125c-.30468,0-.60205-.0039-.90429-.0107a12.68714,12.68714,0,0,1-1.86914-.1631,5.88381,5.88381,0,0,1-1.65674-.5479,5.40573,5.40573,0,0,1-1.397-1.0166,5.32082,5.32082,0,0,1-1.02051-1.3965,5.72184,5.72184,0,0,1-.543-1.6572,12.41339,12.41339,0,0,1-.1665-1.875c-.00634-.2109-.01464-.9131-.01464-.9131V8.44434S.88185,7.75293.8877,7.5498a12.37032,12.37032,0,0,1,.16553-1.87207,5.75552,5.75552,0,0,1,.54346-1.6621A5.3735,5.3735,0,0,1,2.61183,2.61768,5.56543,5.56543,0,0,1,4.01417,1.59521a5.82309,5.82309,0,0,1,1.65332-.54394A12.58589,12.58589,0,0,1,7.543.88721L8.44532.875h139.205l.9131.0127a12.38493,12.38493,0,0,1,1.8584.16259,5.93833,5.93833,0,0,1,1.6709.54785,5.59374,5.59374,0,0,1,2.415,2.41993,5.76267,5.76267,0,0,1,.5352,1.64892,12.995,12.995,0,0,1,.1738,1.88721c.0029.2832.0029.5874.0029.89014.0079.375.0079.73193.0079,1.09179V30.4648c0,.3633,0,.7178-.0079,1.0752,0,.3252,0,.6231-.0039.9297a12.73127,12.73127,0,0,1-.1709,1.8535,5.739,5.739,0,0,1-.54,1.67,5.48029,5.48029,0,0,1-1.0156,1.3857,5.4129,5.4129,0,0,1-1.3994,1.0225,5.86168,5.86168,0,0,1-1.668.5498,12.54218,12.54218,0,0,1-1.8692.1631c-.2929.0068-.5996.0107-.8974.0107l-1.084.002Z"/>
|
||||||
|
</g>
|
||||||
|
<g id="_Group_" data-name="<Group>">
|
||||||
|
<g id="_Group_2" data-name="<Group>">
|
||||||
|
<g id="_Group_3" data-name="<Group>">
|
||||||
|
<g id="_Group_4" data-name="<Group>">
|
||||||
|
<path id="_Path_" data-name="<Path>" d="M24.76888,20.30068a4.94881,4.94881,0,0,1,2.35656-4.15206,5.06566,5.06566,0,0,0-3.99116-2.15768c-1.67924-.17626-3.30719,1.00483-4.1629,1.00483-.87227,0-2.18977-.98733-3.6085-.95814a5.31529,5.31529,0,0,0-4.47292,2.72787c-1.934,3.34842-.49141,8.26947,1.3612,10.97608.9269,1.32535,2.01018,2.8058,3.42763,2.7533,1.38706-.05753,1.9051-.88448,3.5794-.88448,1.65876,0,2.14479.88448,3.591.8511,1.48838-.02416,2.42613-1.33124,3.32051-2.66914a10.962,10.962,0,0,0,1.51842-3.09251A4.78205,4.78205,0,0,1,24.76888,20.30068Z" style="fill: #fff"/>
|
||||||
|
<path id="_Path_2" data-name="<Path>" d="M22.03725,12.21089a4.87248,4.87248,0,0,0,1.11452-3.49062,4.95746,4.95746,0,0,0-3.20758,1.65961,4.63634,4.63634,0,0,0-1.14371,3.36139A4.09905,4.09905,0,0,0,22.03725,12.21089Z" style="fill: #fff"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path d="M46.14895,30.49609V21.35645H46.0884l-3.74316,9.04492H40.91652l-3.75293-9.04492H37.104v9.13965H35.34816v-12.418h2.22949l4.01855,9.80176h.06836l4.01074-9.80176h2.2373v12.418Z" style="fill: #fff"/>
|
||||||
|
<path d="M49.396,27.92285c0-1.583,1.21289-2.53906,3.36523-2.668l2.47852-.1377v-.68848c0-1.00684-.66309-1.5752-1.791-1.5752a1.73035,1.73035,0,0,0-1.90137,1.27441H49.8091c.05176-1.63574,1.5752-2.79687,3.69141-2.79687,2.16016,0,3.58887,1.17871,3.58887,2.96v6.20508H55.30813V29.00684h-.043a3.23683,3.23683,0,0,1-2.85742,1.64453A2.74447,2.74447,0,0,1,49.396,27.92285Zm5.84375-.81738V26.4082l-2.22949.1377c-1.11035.06934-1.73828.55078-1.73828,1.3252,0,.792.6543,1.30859,1.65234,1.30859A2.17046,2.17046,0,0,0,55.23977,27.10547Z" style="fill: #fff"/>
|
||||||
|
<path d="M64.89309,24.55762a1.99909,1.99909,0,0,0-2.13379-1.66895c-1.42871,0-2.375,1.19629-2.375,3.08105,0,1.92773.95508,3.08887,2.3916,3.08887a1.94829,1.94829,0,0,0,2.11719-1.626h1.79A3.61835,3.61835,0,0,1,62.7593,30.6084c-2.582,0-4.26855-1.76465-4.26855-4.63867,0-2.81445,1.68652-4.63867,4.251-4.63867a3.63931,3.63931,0,0,1,3.9248,3.22656Z" style="fill: #fff"/>
|
||||||
|
<path d="M78.7593,27.13965H74.0259l-1.13672,3.35645H70.8843l4.4834-12.418h2.083l4.4834,12.418H79.895Zm-4.24316-1.54883h3.752l-1.84961-5.44727h-.05176Z" style="fill: #fff"/>
|
||||||
|
<path d="M91.61672,25.96973c0,2.81348-1.50586,4.62109-3.77832,4.62109a3.0693,3.0693,0,0,1-2.84863-1.584h-.043v4.48438H83.0884V21.44238h1.79883v1.50586h.03418a3.21161,3.21161,0,0,1,2.88281-1.60059C90.10207,21.34766,91.61672,23.16406,91.61672,25.96973Zm-1.91016,0c0-1.833-.94727-3.03809-2.39258-3.03809-1.41992,0-2.375,1.23047-2.375,3.03809,0,1.82422.95508,3.0459,2.375,3.0459C88.7593,29.01563,89.70656,27.81934,89.70656,25.96973Z" style="fill: #fff"/>
|
||||||
|
<path d="M101.58156,25.96973c0,2.81348-1.50586,4.62109-3.77832,4.62109a3.0693,3.0693,0,0,1-2.84863-1.584h-.043v4.48438h-1.8584V21.44238h1.79883v1.50586h.03418a3.21162,3.21162,0,0,1,2.88281-1.60059C100.06691,21.34766,101.58156,23.16406,101.58156,25.96973Zm-1.91016,0c0-1.833-.94727-3.03809-2.39258-3.03809-1.41992,0-2.375,1.23047-2.375,3.03809,0,1.82422.95508,3.0459,2.375,3.0459C98.72414,29.01563,99.67141,27.81934,99.67141,25.96973Z" style="fill: #fff"/>
|
||||||
|
<path d="M108.1675,27.03613c.1377,1.23145,1.334,2.04,2.96875,2.04,1.56641,0,2.69336-.80859,2.69336-1.91895,0-.96387-.67969-1.541-2.28906-1.93652l-1.60937-.3877c-2.28027-.55078-3.33887-1.61719-3.33887-3.34766,0-2.14258,1.86719-3.61426,4.51855-3.61426,2.624,0,4.42285,1.47168,4.4834,3.61426h-1.876c-.1123-1.23926-1.13672-1.9873-2.63379-1.9873s-2.52149.75684-2.52149,1.8584c0,.87793.65431,1.39453,2.25489,1.79l1.36816.33594c2.54785.60254,3.60645,1.626,3.60645,3.44238,0,2.32324-1.85059,3.77832-4.79395,3.77832-2.75391,0-4.61328-1.4209-4.7334-3.667Z" style="fill: #fff"/>
|
||||||
|
<path d="M119.80324,19.2998v2.14258h1.72168v1.47168h-1.72168v4.99121c0,.77539.34473,1.13672,1.10156,1.13672a5.80752,5.80752,0,0,0,.61133-.043v1.46289a5.10351,5.10351,0,0,1-1.03223.08594c-1.833,0-2.54785-.68848-2.54785-2.44434V22.91406h-1.31641V21.44238h1.31641V19.2998Z" style="fill: #fff"/>
|
||||||
|
<path d="M122.521,25.96973c0-2.84863,1.67773-4.63867,4.29395-4.63867,2.625,0,4.29492,1.79,4.29492,4.63867,0,2.85645-1.66113,4.63867-4.29492,4.63867C124.18215,30.6084,122.521,28.82617,122.521,25.96973Zm6.69531,0c0-1.9541-.89551-3.10742-2.40137-3.10742s-2.40137,1.16211-2.40137,3.10742c0,1.96191.89551,3.10645,2.40137,3.10645S129.21633,27.93164,129.21633,25.96973Z" style="fill: #fff"/>
|
||||||
|
<path d="M132.64309,21.44238h1.77246v1.541h.043a2.1594,2.1594,0,0,1,2.17773-1.63574,2.86616,2.86616,0,0,1,.63672.06934v1.73828a2.598,2.598,0,0,0-.835-.1123,1.87264,1.87264,0,0,0-1.93651,2.083v5.37012h-1.8584Z" style="fill: #fff"/>
|
||||||
|
<path d="M145.84035,27.83691c-.25,1.64355-1.85059,2.77148-3.89844,2.77148-2.63379,0-4.26855-1.76465-4.26855-4.5957,0-2.83984,1.64355-4.68164,4.19043-4.68164,2.50488,0,4.08008,1.7207,4.08008,4.46582v.63672h-6.39453v.1123a2.358,2.358,0,0,0,2.43555,2.56445,2.04834,2.04834,0,0,0,2.09082-1.27344Zm-6.28223-2.70215h4.52637a2.1773,2.1773,0,0,0-2.2207-2.29785A2.292,2.292,0,0,0,139.55813,25.13477Z" style="fill: #fff"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g id="_Group_5" data-name="<Group>">
|
||||||
|
<g>
|
||||||
|
<path d="M37.82619,8.731a2.63964,2.63964,0,0,1,2.80762,2.96484c0,1.90625-1.03027,3.002-2.80762,3.002H35.67092V8.731Zm-1.22852,5.123h1.125a1.87588,1.87588,0,0,0,1.96777-2.146,1.881,1.881,0,0,0-1.96777-2.13379h-1.125Z" style="fill: #fff"/>
|
||||||
|
<path d="M41.68068,12.44434a2.13323,2.13323,0,1,1,4.24707,0,2.13358,2.13358,0,1,1-4.24707,0Zm3.333,0c0-.97607-.43848-1.54687-1.208-1.54687-.77246,0-1.207.5708-1.207,1.54688,0,.98389.43457,1.55029,1.207,1.55029C44.57521,13.99463,45.01369,13.42432,45.01369,12.44434Z" style="fill: #fff"/>
|
||||||
|
<path d="M51.57326,14.69775h-.92187l-.93066-3.31641h-.07031l-.92676,3.31641h-.91309l-1.24121-4.50293h.90137l.80664,3.436h.06641l.92578-3.436h.85254l.92578,3.436h.07031l.80273-3.436h.88867Z" style="fill: #fff"/>
|
||||||
|
<path d="M53.85354,10.19482H54.709v.71533h.06641a1.348,1.348,0,0,1,1.34375-.80225,1.46456,1.46456,0,0,1,1.55859,1.6748v2.915h-.88867V12.00586c0-.72363-.31445-1.0835-.97168-1.0835a1.03294,1.03294,0,0,0-1.0752,1.14111v2.63428h-.88867Z" style="fill: #fff"/>
|
||||||
|
<path d="M59.09377,8.437h.88867v6.26074h-.88867Z" style="fill: #fff"/>
|
||||||
|
<path d="M61.21779,12.44434a2.13323,2.13323,0,1,1,4.24707,0,2.13358,2.13358,0,1,1-4.24707,0Zm3.333,0c0-.97607-.43848-1.54687-1.208-1.54687-.77246,0-1.207.5708-1.207,1.54688,0,.98389.43457,1.55029,1.207,1.55029C64.11232,13.99463,64.5508,13.42432,64.5508,12.44434Z" style="fill: #fff"/>
|
||||||
|
<path d="M66.40041,13.42432c0-.81055.60352-1.27783,1.6748-1.34424l1.21973-.07031v-.38867c0-.47559-.31445-.74414-.92187-.74414-.49609,0-.83984.18213-.93848.50049h-.86035c.09082-.77344.81836-1.26953,1.83984-1.26953,1.12891,0,1.76563.562,1.76563,1.51318v3.07666h-.85547v-.63281h-.07031a1.515,1.515,0,0,1-1.35254.707A1.36026,1.36026,0,0,1,66.40041,13.42432Zm2.89453-.38477v-.37646l-1.09961.07031c-.62012.0415-.90137.25244-.90137.64941,0,.40527.35156.64111.835.64111A1.0615,1.0615,0,0,0,69.29494,13.03955Z" style="fill: #fff"/>
|
||||||
|
<path d="M71.34768,12.44434c0-1.42285.73145-2.32422,1.86914-2.32422a1.484,1.484,0,0,1,1.38086.79h.06641V8.437h.88867v6.26074h-.85156v-.71143h-.07031a1.56284,1.56284,0,0,1-1.41406.78564C72.07131,14.772,71.34768,13.87061,71.34768,12.44434Zm.918,0c0,.95508.4502,1.52979,1.20313,1.52979.749,0,1.21191-.583,1.21191-1.52588,0-.93848-.46777-1.52979-1.21191-1.52979C72.72072,10.91846,72.26564,11.49707,72.26564,12.44434Z" style="fill: #fff"/>
|
||||||
|
<path d="M79.22951,12.44434a2.13346,2.13346,0,1,1,4.24756,0,2.1338,2.1338,0,1,1-4.24756,0Zm3.333,0c0-.97607-.43848-1.54687-1.208-1.54687-.77246,0-1.207.5708-1.207,1.54688,0,.98389.43457,1.55029,1.207,1.55029C82.124,13.99463,82.56252,13.42432,82.56252,12.44434Z" style="fill: #fff"/>
|
||||||
|
<path d="M84.66945,10.19482h.85547v.71533h.06641a1.348,1.348,0,0,1,1.34375-.80225,1.46456,1.46456,0,0,1,1.55859,1.6748v2.915H87.605V12.00586c0-.72363-.31445-1.0835-.97168-1.0835a1.03294,1.03294,0,0,0-1.0752,1.14111v2.63428h-.88867Z" style="fill: #fff"/>
|
||||||
|
<path d="M93.51516,9.07373v1.1416h.97559v.74854h-.97559V13.2793c0,.47168.19434.67822.63672.67822a2.96657,2.96657,0,0,0,.33887-.02051v.74023a2.9155,2.9155,0,0,1-.4834.04541c-.98828,0-1.38184-.34766-1.38184-1.21582v-2.543h-.71484v-.74854h.71484V9.07373Z" style="fill: #fff"/>
|
||||||
|
<path d="M95.70461,8.437h.88086v2.48145h.07031a1.3856,1.3856,0,0,1,1.373-.80664,1.48339,1.48339,0,0,1,1.55078,1.67871v2.90723H98.69v-2.688c0-.71924-.335-1.0835-.96289-1.0835a1.05194,1.05194,0,0,0-1.13379,1.1416v2.62988h-.88867Z" style="fill: #fff"/>
|
||||||
|
<path d="M104.76125,13.48193a1.828,1.828,0,0,1-1.95117,1.30273A2.04531,2.04531,0,0,1,100.73,12.46045a2.07685,2.07685,0,0,1,2.07617-2.35254c1.25293,0,2.00879.856,2.00879,2.27V12.688h-3.17969v.0498a1.1902,1.1902,0,0,0,1.19922,1.29,1.07934,1.07934,0,0,0,1.07129-.5459Zm-3.126-1.45117h2.27441a1.08647,1.08647,0,0,0-1.1084-1.1665A1.15162,1.15162,0,0,0,101.63527,12.03076Z" style="fill: #fff"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 12 KiB |
Reference in New Issue
Block a user