Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions .github/workflows/cli.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
name: cli

on:
push:
branches: [main]
paths:
- "cli/**"
- ".github/workflows/cli.yml"
- "Makefile"
pull_request:
paths:
- "cli/**"
- ".github/workflows/cli.yml"
- "Makefile"

jobs:
build:
runs-on: ubuntu-latest
defaults:
run:
working-directory: cli
steps:
- uses: actions/checkout@v4

- uses: pnpm/action-setup@v4
with:
version: 10

- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
cache-dependency-path: cli/pnpm-lock.yaml

- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Typecheck
run: pnpm typecheck

- name: Test
run: pnpm test

- name: Build
run: pnpm build
9 changes: 8 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.PHONY: bootstrap ios backend-dev backend-test db-migrate db-generate lint fmt clean web-dev web-build web-deploy testflight testflight-ios testflight-macos testflight-doctor testflight-bootstrap appstore-doctor appstore-screenshots appstore-privacy appstore-metadata appstore-metadata-ios appstore-metadata-macos appstore-sync appstore-submit
.PHONY: bootstrap ios backend-dev backend-test db-migrate db-generate lint fmt clean web-dev web-build web-deploy cli-test cli-build testflight testflight-ios testflight-macos testflight-doctor testflight-bootstrap appstore-doctor appstore-screenshots appstore-privacy appstore-metadata appstore-metadata-ios appstore-metadata-macos appstore-sync appstore-submit

BUNDLER_VERSION ?= 2.4.22
BUNDLE ?= bundle _$(BUNDLER_VERSION)_
Expand Down Expand Up @@ -35,6 +35,7 @@ fmt:

clean:
rm -rf backend/node_modules backend/dist backend/.wrangler
rm -rf cli/node_modules cli/dist cli/dist-release
rm -rf apple/FastShared.xcodeproj apple/FastShared.xcworkspace
rm -rf apple/build apple/DerivedData

Expand All @@ -47,6 +48,12 @@ web-build:
web-deploy:
cd web && pnpm build && pnpm dlx wrangler pages deploy dist --project-name fastshared-web

cli-test:
cd cli && pnpm test

cli-build:
cd cli && pnpm typecheck && pnpm test && pnpm build

# --- TestFlight ---------------------------------------------------------------
# First-time setup: `make testflight-bootstrap` (installs fastlane via bundler).
# Before uploading: put your App Store Connect API key details in
Expand Down
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
·
<a href="./docs/product/overview.md">Product overview</a>
·
<a href="./docs/product/cli.md">CLI</a>
·
<a href="./docs/architecture/security.md">Security</a>
·
<a href="./docs/ops/testflight-setup.md">TestFlight runbook</a>
Expand All @@ -42,6 +44,7 @@ Every link expires. Every file is deleted. By design.
| ------- | ---------- |
| iPhone and iPad | Native Share Extension, background uploads, Live Activity, and Dynamic Island progress. |
| Mac | Menu bar workflow, drag-and-drop uploads, paste-to-upload command, and structured recent links. |
| CLI | Scriptable uploads for agents and shell workflows; stdout returns the temporary URL. |
| Recipient | Opens a short link in any browser. No account, app, or sign-in required. |

## Product limits
Expand Down Expand Up @@ -85,6 +88,7 @@ workflow, with storage lifecycle rules as a safety net.
| Layer | Stack |
| ----- | ----- |
| Apple | SwiftUI, SwiftData, Share Extension, background `URLSession`, Keychain Sharing, CloudKit metadata sync |
| CLI | Node.js 20+, TypeScript, native `fetch`, local ZIP staging |
| Backend | Cloudflare Workers, Hono, Drizzle ORM, Neon Postgres, R2, KV |
| Web | Astro landing/docs surfaces |
| CI/CD | GitHub Actions, Xcode/TestFlight lanes, Wrangler deploy workflows |
Expand All @@ -94,6 +98,7 @@ workflow, with storage lifecycle rules as a safety net.
| Path | Purpose |
| ---- | ------- |
| `apple/` | iOS, iPadOS, macOS app targets, Share Extension, shared Swift package |
| `cli/` | Node/TypeScript command-line uploader for agents and scripts |
| `backend/` | Hono Worker API, persistence, billing verification, retention jobs |
| `web/` | Public site and static marketing surfaces |
| `docs/` | Product, architecture, security, launch, and ops documentation |
Expand Down Expand Up @@ -125,6 +130,15 @@ pnpm install
pnpm build
```

CLI:

```bash
cd cli
pnpm install
pnpm test
pnpm build
```

## Validation

Backend:
Expand All @@ -149,9 +163,19 @@ cd web
pnpm build
```

CLI:

```bash
cd cli
pnpm typecheck
pnpm test
pnpm build
```

## Documentation

- [Product overview](./docs/product/overview.md)
- [CLI](./docs/product/cli.md)
- [System design](./docs/architecture/system-design.md)
- [Apple client](./docs/architecture/apple-client.md)
- [Backend](./docs/architecture/backend.md)
Expand Down
2 changes: 1 addition & 1 deletion apple/Config/Shared.xcconfig
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ SWIFT_VERSION = 5.10
// MARKETING_VERSION: human-readable release version (CFBundleShortVersionString).
// Bumped on each public release following semver; CI may override for TestFlight
// pre-releases.
MARKETING_VERSION = 1.0.0
MARKETING_VERSION = 1.0.1

// CURRENT_PROJECT_VERSION: build number (CFBundleVersion). Must be monotonically
// increasing for every archive uploaded to App Store Connect under the same
Expand Down
1 change: 1 addition & 0 deletions apple/FastSharedApp/Scenes/HistoryView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,7 @@ struct HistoryView: View {
case .retentionTooLong(let seconds, _):
let policy: RetentionPolicy = {
switch seconds {
case 0...60: return .oneMinute
case 0...3600: return .oneHour
case 0...86_400: return .oneDay
case 0...604_800: return .oneWeek
Expand Down
1 change: 1 addition & 0 deletions apple/FastSharedApp/Scenes/LibraryView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,7 @@ struct LibraryView: View {
case .retentionTooLong(let seconds, _):
let policy: RetentionPolicy = {
switch seconds {
case 0...60: return .oneMinute
case 0...3600: return .oneHour
case 0...86_400: return .oneDay
case 0...604_800: return .oneWeek
Expand Down
1 change: 1 addition & 0 deletions apple/FastSharedApp/Scenes/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -844,6 +844,7 @@ private struct FriendlyToggle: View {
private extension RetentionPolicy {
var shortLabel: String {
switch self {
case .oneMinute: return "60s"
case .oneHour: return "1h"
case .oneDay: return "24h"
case .oneWeek: return "1w"
Expand Down
11 changes: 7 additions & 4 deletions apple/FastSharedShareExt/ShareRootView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -740,10 +740,11 @@ private struct IdleStage: View {

private func chipLabel(for policy: RetentionPolicy) -> String {
switch policy {
case .oneHour: return "1 hour"
case .oneDay: return "24 hours"
case .oneWeek: return "3 days"
case .oneMonth: return "7 days"
case .oneMinute: return "60s"
case .oneHour: return "1h"
case .oneDay: return "24h"
case .oneWeek: return "7d"
case .oneMonth: return "30d"
default: return policy.displayName
}
}
Expand Down Expand Up @@ -937,6 +938,7 @@ private struct SuccessStage: View {

private var retentionBodyText: String {
switch retention {
case .oneMinute: return "60 seconds"
case .oneHour: return "1 hour"
case .oneDay: return "24 hours"
case .oneWeek: return "7 days"
Expand Down Expand Up @@ -1137,6 +1139,7 @@ private struct BundleSuccessStage: View {

private var retentionBodyText: String {
switch retention {
case .oneMinute: return "60 seconds"
case .oneHour: return "1 hour"
case .oneDay: return "24 hours"
case .oneWeek: return "7 days"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ public extension BundleUploadAttributes {
/// can use the same pill component without branching on attribute type.
var retentionBadge: String {
switch retentionPolicy {
case RetentionPolicy.oneMinute.rawValue: return "60s"
case RetentionPolicy.oneHour.rawValue: return "1h"
case RetentionPolicy.oneDay.rawValue: return "24h"
case RetentionPolicy.oneWeek.rawValue: return "7d"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,10 @@ public struct FastSharedActivityAttributes: ActivityAttributes, Sendable, Hashab
}

public extension FastSharedActivityAttributes {
/// Convenience for the retention badge ("1h" / "24h" / "7d" / "30d").
/// Convenience for the retention badge ("60s" / "1h" / "24h" / "7d" / "30d").
var retentionBadge: String {
switch retentionPolicy {
case RetentionPolicy.oneMinute.rawValue: return "60s"
case RetentionPolicy.oneHour.rawValue: return "1h"
case RetentionPolicy.oneDay.rawValue: return "24h"
case RetentionPolicy.oneWeek.rawValue: return "7d"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import Foundation

public enum RetentionPolicy: String, CaseIterable, Sendable, Codable {
case oneMinute
case oneHour
case oneDay
case oneWeek
Expand All @@ -9,6 +10,7 @@ public enum RetentionPolicy: String, CaseIterable, Sendable, Codable {

public var ttlSeconds: TimeInterval {
switch self {
case .oneMinute: return 60
case .oneHour: return 3600
case .oneDay: return 86_400
case .oneWeek: return 604_800
Expand All @@ -19,6 +21,7 @@ public enum RetentionPolicy: String, CaseIterable, Sendable, Codable {

public var displayName: String {
switch self {
case .oneMinute: return "60 seconds"
case .oneHour: return "1 hour"
case .oneDay: return "1 day"
case .oneWeek: return "1 week"
Expand All @@ -31,7 +34,7 @@ public enum RetentionPolicy: String, CaseIterable, Sendable, Codable {

// WHY: .custom is intentionally excluded from the share extension picker in MVP; power users
// can come later via a settings-level override.
public static let shareable: [RetentionPolicy] = [.oneHour, .oneDay, .oneWeek, .oneMonth]
public static let shareable: [RetentionPolicy] = [.oneMinute, .oneHour, .oneDay, .oneWeek, .oneMonth]

/// Reads the user's preferred retention from the shared App Group suite, falling back to `.default`.
/// Used by surfaces that run outside the SwiftUI environment (App Intents, headless entry points).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ public struct GatedRetentionPicker: View {

private func shortLabel(for policy: RetentionPolicy) -> String {
switch policy {
case .oneMinute: return "60s"
case .oneHour: return "1h"
case .oneDay: return "24h"
case .oneWeek: return "1w"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,15 @@ final class RetentionPolicyTests: XCTestCase {
}

func test_all_ttls_are_reasonable() {
XCTAssertEqual(RetentionPolicy.oneMinute.ttlSeconds, 60)
XCTAssertEqual(RetentionPolicy.oneHour.ttlSeconds, 3600)
XCTAssertEqual(RetentionPolicy.oneWeek.ttlSeconds, 604_800)
XCTAssertEqual(RetentionPolicy.oneMonth.ttlSeconds, 2_592_000)
XCTAssertTrue(RetentionPolicy.custom.ttlSeconds.isNaN)
}

func test_display_names_are_human() {
XCTAssertEqual(RetentionPolicy.oneMinute.displayName, "60 seconds")
XCTAssertEqual(RetentionPolicy.oneHour.displayName, "1 hour")
XCTAssertEqual(RetentionPolicy.oneDay.displayName, "1 day")
XCTAssertEqual(RetentionPolicy.oneWeek.displayName, "1 week")
Expand All @@ -26,7 +28,7 @@ final class RetentionPolicyTests: XCTestCase {
}

func test_shareable_excludes_custom() {
XCTAssertEqual(RetentionPolicy.shareable, [.oneHour, .oneDay, .oneWeek, .oneMonth])
XCTAssertEqual(RetentionPolicy.shareable, [.oneMinute, .oneHour, .oneDay, .oneWeek, .oneMonth])
XCTAssertFalse(RetentionPolicy.shareable.contains(.custom))
}

Expand Down
2 changes: 1 addition & 1 deletion apple/fastlane/Deliverfile
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

app_identifier("dev.kindrazki.fastshared")
team_id("YFYB6NKC73")
app_version(ENV["FASTSHARED_APPSTORE_VERSION"] || "1.0.0")
app_version(ENV["FASTSHARED_APPSTORE_VERSION"] || "1.0.1")

metadata_path("./fastlane/metadata")
screenshots_path("./fastlane/screenshots/ios")
Expand Down
2 changes: 1 addition & 1 deletion apple/fastlane/metadata/en-US/description.txt
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ FastShared is a native Apple utility that turns "share a file" into "get a tempo
WHAT IT DOES
- Share any file from any app through the iOS share sheet.
- Drag a file onto the Mac app, or paste anything with Command-V.
- Pick how long the link lives: 1 hour, 24 hours, 1 week, 1 month, or custom from 5 minutes to 30 days.
- Pick how long the link lives: 60 seconds, 1 hour, 24 hours, 1 week, 1 month, or custom from 5 minutes to 30 days.
- The short link lands on your clipboard. Paste it anywhere.
- Watch upload progress in a Live Activity and Dynamic Island on supported iPhones.
- Browse recent links with a live countdown. Revoke any link instantly.
Expand Down
9 changes: 8 additions & 1 deletion backend/src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,14 @@ export const asset = pgTable(
export const LINK_STATUSES = ['pending', 'active', 'expired', 'revoked'] as const;
export type LinkStatus = (typeof LINK_STATUSES)[number];

export const RETENTION_POLICIES = ['oneHour', 'oneDay', 'oneWeek', 'oneMonth', 'custom'] as const;
export const RETENTION_POLICIES = [
'oneMinute',
'oneHour',
'oneDay',
'oneWeek',
'oneMonth',
'custom',
] as const;
export type RetentionPolicy = (typeof RETENTION_POLICIES)[number];

export const shareLink = pgTable(
Expand Down
2 changes: 2 additions & 0 deletions backend/src/lib/retention.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const MIN_CUSTOM_SECONDS = 300;
const MAX_CUSTOM_SECONDS = 2_592_000; // 30 days

const PRESET_SECONDS: Record<Exclude<RetentionPolicy, 'custom'>, number> = {
oneMinute: 60,
oneHour: 3_600,
oneDay: 86_400,
oneWeek: 604_800,
Expand Down Expand Up @@ -40,6 +41,7 @@ export function resolveRetention(

function asRetentionPolicy(value: string): RetentionPolicy {
switch (value) {
case 'oneMinute':
case 'oneHour':
case 'oneDay':
case 'oneWeek':
Expand Down
2 changes: 2 additions & 0 deletions backend/src/middleware/rateLimitFreeTier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,8 @@ export function rateLimitFreeTier(): MiddlewareHandler<AppBindings> {

function ttlSecondsForPolicy(policy: string, customTtlSeconds?: number): number | null {
switch (policy) {
case 'oneMinute':
return 60;
case 'oneHour':
return 3600;
case 'oneDay':
Expand Down
2 changes: 1 addition & 1 deletion backend/src/routes/devices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { toBase64Url } from '~/lib/hash';
import { ratelimit } from '~/middleware/ratelimit';

const registerSchema = z.object({
platform: z.enum(['ios', 'ipados', 'macos']),
platform: z.enum(['ios', 'ipados', 'macos', 'cli']),
appVersion: z.string().min(1).max(64),
idfv: z.string().min(1).max(128).optional(),
});
Expand Down
6 changes: 3 additions & 3 deletions backend/src/routes/redirect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,6 @@ async function loadActiveLinkAndAsset(
const link = await findByToken(db, token);
if (!link) return { status: 'not_found' };
if (link.linkStatus === 'revoked') return { status: 'gone', reason: 'revoked' };
// Pending: asset hasn't landed yet. Recipient sees the "Uploading…" page.
if (link.linkStatus === 'pending') return { status: 'pending', link };
const now = Date.now();
if (link.expiresAt.getTime() <= now) {
// Lazy flip so the stored state matches reality for observers.
Expand All @@ -85,6 +83,8 @@ async function loadActiveLinkAndAsset(
);
return { status: 'gone', reason: 'expired' };
}
// Pending: asset hasn't landed yet. Recipient sees the "Uploading…" page.
if (link.linkStatus === 'pending') return { status: 'pending', link };
// assetId is nullable on the schema (pending rows), but any non-pending
// state must have an asset — if it's missing the link is effectively gone.
if (!link.assetId) return { status: 'gone', reason: 'deleted' };
Expand Down Expand Up @@ -470,7 +470,6 @@ async function loadActiveBundle(
const link = await findByToken(db, token);
if (!link || !link.isBundle) return { status: 'not_found' };
if (link.linkStatus === 'revoked') return { status: 'gone', reason: 'revoked' };
if (link.linkStatus === 'pending') return { status: 'pending', link };
if (link.expiresAt.getTime() <= Date.now()) {
c.executionCtx.waitUntil(
markExpiredByToken(db, token).catch((err) => {
Expand All @@ -483,6 +482,7 @@ async function loadActiveBundle(
);
return { status: 'gone', reason: 'expired' };
}
if (link.linkStatus === 'pending') return { status: 'pending', link };
// Two queries (junction + assets) instead of a JOIN — keeps the route
// simple and lets the test fake stay dumb. Assets in displayOrder.
const junctions = await db
Expand Down
Loading
Loading