Compare commits

...

11 Commits

Author SHA1 Message Date
e8ae1cbec4 overlay fixes 2025-10-20 00:58:33 +02:00
b72ea4f417 some fixes 2025-10-20 00:50:21 +02:00
bf0ffe8632 rescan fix 2025-10-20 00:38:52 +02:00
96bfaaf64b lower power consumption bluetooth 2025-10-20 00:34:25 +02:00
cf5a848741 app fixes 2025-10-20 00:04:18 +02:00
7c492627f0 lockscreen show progressbar only on hold 2025-10-19 23:48:14 +02:00
be2629a008 connected overlay in file manager 2025-10-19 23:27:27 +02:00
016629eb82 lockscreen app 2025-10-19 23:27:16 +02:00
de1ac0e7a2 fixie 2025-10-19 23:09:55 +02:00
3ab2a7bf26 somewhat working file sync 2025-10-19 23:07:20 +02:00
b4f11851d7 fix 2025-10-19 20:30:11 +02:00
19 changed files with 6687 additions and 80 deletions

View File

@@ -25,5 +25,65 @@ This SwiftUI app connects to the Cardboy device over Bluetooth Low Energy and up
2. Ensure the `CoreBluetooth` capability is enabled for the `cardboy-companion` target and keep the *Uses Bluetooth LE accessories* background mode on (preconfigured in this project).
3. Build & run on a real device (BLE is not available in the simulator).
4. Allow Bluetooth permissions when prompted. The app keeps scanning in the background, so the Cardboy can request a sync even while the companion is not foregrounded. Tap **Sync Now** any time you want to trigger a manual refresh.
5. Switch to the **Files** tab to browse the LittleFS volume on the Cardboy: you can upload ROMs from the Files picker, create/remove folders, rename entries, delete files, and download items back to the phone for sharing.
## BLE File Service Protocol
The ESP firmware exposes a custom GATT service (UUID `00000010-CA7B-4EFD-B5A6-10C3F4D3F230`) with two characteristics:
| Characteristic | UUID | Properties | Direction | Description |
| --- | --- | --- | --- | --- |
| File Command | `00000011-CA7B-4EFD-B5A6-10C3F4D3F231` | Write / Write Without Response | iOS → ESP | Sends file management requests |
| File Response | `00000012-CA7B-4EFD-B5A6-10C3F4D3F232` | Notify | ESP → iOS | Streams command results (responses or data) |
All payloads share the same framing. Commands written to the File Command characteristic use:
```
Offset | Size | Description
-------+------+------------
0 | 1 | Opcode (see table below)
1 | 1 | Reserved (set to 0)
2 | 2 | Little-endian payload length in bytes (N)
4 | N | Command payload
```
Notifications from the File Response characteristic use:
```
Offset | Size | Description
-------+------+------------
0 | 1 | Opcode (echoed from command)
1 | 1 | Status byte (bit 7 = completion flag; lower 7 bits = error code)
2 | 2 | Little-endian payload length (N)
4 | N | Response payload (command-specific)
```
Status byte semantics:
- Bit 7 (0x80) set → final packet for the current command (no further fragments).
- Lower 7 bits = error code (`0` = success, otherwise `errno`-style code echoed back).
- On error the response payload may contain a UTF-8 message.
### Opcodes and Payloads
| Opcode | Name | Command Payload | Response Payload |
| --- | --- | --- | --- |
| `0x01` | List Directory | `uint16 path_len` + UTF-8 path | One or more fragments, each entry encoded as:<br> - `uint8 type` (0=file, 1=dir)<br> - `uint8 reserved`<br> - `uint16 name_len`<br> - `uint32 size` (0 for dirs)<br> - `name_len` bytes UTF-8 name<br> Final notification has completion bit set. |
| `0x02` | Upload Begin | `uint16 path_len` + UTF-8 path + `uint32 file_size` | Empty payload on success. Starts upload session (expects `UploadChunk` packets). |
| `0x03` | Upload Chunk | Raw file bytes | Empty payload ack for each chunk. |
| `0x04` | Upload End | No payload | Empty payload confirming completion. |
| `0x05` | Download Request | `uint16 path_len` + UTF-8 path | First notification: 4-byte little-endian total file size; subsequent notifications stream raw file data fragments. Completion bit marks the final chunk. |
| `0x06` | Delete File | `uint16 path_len` + UTF-8 path | Empty payload on success. |
| `0x07` | Create Directory | `uint16 path_len` + UTF-8 path | Empty payload on success. |
| `0x08` | Delete Directory | `uint16 path_len` + UTF-8 path | Empty payload on success. |
| `0x09` | Rename Path | `uint16 src_len` + UTF-8 source path + `uint16 dst_len` + UTF-8 destination path | Empty payload on success. |
### Notes
- Paths are absolute within the LittleFS volume; the firmware normalizes them and rejects entries containing `..`.
- Large responses (directory lists, downloads) may arrive in multiple notifications; the iOS client aggregates fragments until it sees the completion flag.
- Uploads are initiated with `Upload Begin` (including total size), followed by one or more `Upload Chunk` writes, and `Upload End` when done.
- Errors from the firmware propagate via the status byte; when `status & 0x7F != 0`, the notification payload typically includes a UTF-8 error message (e.g., `"stat failed"`).
This protocol mirrors the implementation in `components/backend-esp/src/time_sync_service.cpp` and the Swift client in `TimeSyncManager.swift`. Update both sides if new commands are added.
Optionally bundle this code into your existing app—`TimeSyncManager` is selfcontained and can be reused.

View File

@@ -268,6 +268,7 @@
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "cardboy-companion/Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = Cardboy;
INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Cardboy Companion needs Bluetooth to sync time with your handheld.";
INFOPLIST_KEY_NSBluetoothPeripheralUsageDescription = "Cardboy Companion needs Bluetooth to sync time with your handheld.";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
@@ -304,6 +305,7 @@
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "cardboy-companion/Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = Cardboy;
INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Cardboy Companion needs Bluetooth to sync time with your handheld.";
INFOPLIST_KEY_NSBluetoothPeripheralUsageDescription = "Cardboy Companion needs Bluetooth to sync time with your handheld.";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;

View File

@@ -1,6 +1,7 @@
{
"images" : [
{
"filename" : "cardboy-icon.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
@@ -12,6 +13,7 @@
"value" : "dark"
}
],
"filename" : "cardboy-icon-dark.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
@@ -23,6 +25,7 @@
"value" : "tinted"
}
],
"filename" : "cardboy-icon-tinted.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 41 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 41 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 41 KiB

View File

@@ -1,7 +1,48 @@
import Combine
import SwiftUI
import UIKit
import UniformTypeIdentifiers
struct ContentView: View {
@EnvironmentObject private var manager: TimeSyncManager
@State private var selectedTab = 0
@State private var shareURL: URL?
@State private var errorWrapper: ErrorWrapper?
var body: some View {
TabView(selection: $selectedTab) {
TimeSyncTabView()
.tabItem { Label("Clock", systemImage: "clock.arrow.circlepath") }
.tag(0)
FileManagerTabView(shareURL: $shareURL, errorWrapper: $errorWrapper)
.tabItem { Label("Files", systemImage: "folder") }
.tag(1)
}
.sheet(item: $shareURL) { url in
ShareSheet(items: [url])
}
.alert(item: $errorWrapper) { wrapper in
Alert(title: Text("Error"), message: Text(wrapper.message), dismissButton: .default(Text("OK")))
}
.onReceive(manager.$fileErrorMessage.compactMap { $0 }) { message in
errorWrapper = ErrorWrapper(message: message)
manager.fileErrorMessage = nil
}
.onReceive(manager.$downloadedFileURL.compactMap { $0 }) { url in
shareURL = url
manager.downloadedFileURL = nil
}
}
}
private struct ErrorWrapper: Identifiable {
let id = UUID()
let message: String
}
private struct TimeSyncTabView: View {
@EnvironmentObject private var manager: TimeSyncManager
private var formattedLastSync: String {
guard let date = manager.lastSyncDate else { return "Never" }
@@ -52,6 +93,450 @@ struct ContentView: View {
}
}
private struct FileManagerTabView: View {
@EnvironmentObject private var manager: TimeSyncManager
@Binding var shareURL: URL?
@Binding var errorWrapper: ErrorWrapper?
@State private var navigationPath: [String] = []
var body: some View {
ZStack {
NavigationStack(path: $navigationPath) {
DirectoryView(
path: "/",
navigationPath: $navigationPath,
shareURL: $shareURL,
errorWrapper: $errorWrapper
)
.navigationDestination(for: String.self) { destination in
DirectoryView(
path: destination,
navigationPath: $navigationPath,
shareURL: $shareURL,
errorWrapper: $errorWrapper
)
}
}
if let operation = manager.activeFileOperation {
FileOperationHUD(operation: operation)
}
if manager.connectionState != .ready {
ConnectionOverlay(
state: manager.connectionState,
statusMessage: manager.statusMessage,
retryAction: manager.forceRescan
)
}
}
.onAppear {
if manager.currentDirectory != "/" {
manager.changeDirectory(to: "/")
}
navigationPath = stackForPath(manager.currentDirectory)
}
.onChange(of: manager.currentDirectory) { newValue in
let desired = stackForPath(newValue)
if desired != navigationPath {
navigationPath = desired
}
}
.onChange(of: navigationPath) { newValue in
let target = newValue.last ?? "/"
if target != manager.currentDirectory {
manager.changeDirectory(to: target)
}
}
.onChange(of: manager.connectionState) { newState in
if newState == .ready, !manager.isFileBusy {
manager.refreshDirectory()
}
}
}
}
private struct DirectoryView: View {
let path: String
@Binding var navigationPath: [String]
@Binding var shareURL: URL?
@Binding var errorWrapper: ErrorWrapper?
@EnvironmentObject private var manager: TimeSyncManager
@State private var showingImporter = false
@State private var showingNewFolderSheet = false
@State private var showingRenameSheet = false
@State private var newFolderName = ""
@State private var renameText = ""
@State private var renameTarget: TimeSyncManager.RemoteFileEntry?
private var pathSegments: [(name: String, fullPath: String)] {
var segments: [(String, String)] = []
var current = ""
for component in path.split(separator: "/").map(String.init) {
current += "/" + component
segments.append((component, current))
}
return segments
}
private var displayTitle: String {
pathSegments.last?.name ?? "Files"
}
var body: some View {
ZStack {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("Path:")
.font(.headline)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 4) {
Button(action: { navigationPath = [] }) {
Text("/")
}
.buttonStyle(.bordered)
ForEach(Array(pathSegments.enumerated()), id: \.element.fullPath) { index, segment in
Button(action: {
navigationPath = Array(pathSegments.prefix(index + 1).map(\.fullPath))
}) {
Text(segment.name)
}
.buttonStyle(.bordered)
}
}
}
}
.padding(.horizontal)
List {
ForEach(manager.directoryEntries) { entry in
if entry.isDirectory {
NavigationLink(value: entry.path) {
FileRow(entry: entry)
}
.contextMenu {
Button("Open") {
navigationPath = stackForPath(entry.path)
}
Button("Rename") {
renameTarget = entry
renameText = entry.name
showingRenameSheet = true
}
Button(role: .destructive) {
manager.delete(entry: entry)
} label: {
Text("Delete")
}
}
} else {
FileRow(entry: entry)
.contextMenu {
Button("Download") {
manager.download(entry: entry) { result in
switch result {
case .success(let url):
shareURL = url
case .failure(let error):
errorWrapper = ErrorWrapper(message: error.localizedDescription)
}
}
}
Button("Rename") {
renameTarget = entry
renameText = entry.name
showingRenameSheet = true
}
Button(role: .destructive) {
manager.delete(entry: entry)
} label: {
Text("Delete")
}
}
}
}
}
.listStyle(.plain)
HStack(spacing: 12) {
Menu {
Button {
showingImporter = true
} label: {
Label("Upload File", systemImage: "square.and.arrow.up")
}
Button {
showingNewFolderSheet = true
newFolderName = ""
} label: {
Label("New Folder", systemImage: "folder.badge.plus")
}
} label: {
Label("Actions", systemImage: "ellipsis.circle")
}
.buttonStyle(.bordered)
.controlSize(.large)
Spacer()
Button {
manager.refreshDirectory()
} label: {
Label("Refresh", systemImage: "arrow.clockwise")
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
}
.padding([.horizontal, .bottom])
}
}
.navigationTitle(displayTitle)
.navigationBarTitleDisplayMode(.inline)
.onAppear {
if manager.currentDirectory != path {
manager.changeDirectory(to: path)
} else {
manager.refreshDirectory()
}
}
.fileImporter(
isPresented: $showingImporter,
allowedContentTypes: [.data],
allowsMultipleSelection: false
) { result in
switch result {
case .success(let urls):
guard let url = urls.first else { return }
manager.uploadFile(from: url) { uploadResult in
if case let .failure(error) = uploadResult {
errorWrapper = ErrorWrapper(message: error.localizedDescription)
}
}
case .failure(let error):
errorWrapper = ErrorWrapper(message: error.localizedDescription)
}
}
.sheet(isPresented: $showingNewFolderSheet) {
NavigationView {
Form {
Section(header: Text("Folder Name")) {
TextField("Name", text: $newFolderName)
.autocapitalization(.none)
}
}
.navigationTitle("New Folder")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { showingNewFolderSheet = false }
}
ToolbarItem(placement: .confirmationAction) {
Button("Create") {
let name = newFolderName.trimmingCharacters(in: .whitespacesAndNewlines)
if !name.isEmpty {
manager.createDirectory(named: name)
}
showingNewFolderSheet = false
}
}
}
}
}
.sheet(isPresented: $showingRenameSheet) {
NavigationView {
Form {
Section(header: Text("New Name")) {
TextField("Name", text: $renameText)
.autocapitalization(.none)
}
}
.navigationTitle("Rename")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { showingRenameSheet = false }
}
ToolbarItem(placement: .confirmationAction) {
Button("Rename") {
if let target = renameTarget {
let newName = renameText.trimmingCharacters(in: .whitespacesAndNewlines)
if !newName.isEmpty {
manager.rename(entry: target, to: newName)
}
}
showingRenameSheet = false
}
}
}
}
}
}
}
private func stackForPath(_ path: String) -> [String] {
guard path != "/" else { return [] }
var stack: [String] = []
var current = ""
for component in path.split(separator: "/").map(String.init) {
current += "/" + component
stack.append(current)
}
return stack
}
private struct FileRow: View {
let entry: TimeSyncManager.RemoteFileEntry
private var iconName: String {
entry.isDirectory ? "folder" : "doc.text"
}
private var formattedSize: String {
guard !entry.isDirectory else { return "" }
let byteCountFormatter = ByteCountFormatter()
byteCountFormatter.allowedUnits = [.useKB, .useMB, .useGB]
byteCountFormatter.countStyle = .file
return byteCountFormatter.string(fromByteCount: Int64(entry.size))
}
var body: some View {
HStack {
Image(systemName: iconName)
.foregroundColor(entry.isDirectory ? .accentColor : .primary)
VStack(alignment: .leading) {
Text(entry.name)
.fontWeight(entry.isDirectory ? .semibold : .regular)
if !entry.isDirectory, !formattedSize.isEmpty {
Text(formattedSize)
.font(.caption)
.foregroundColor(.secondary)
}
}
Spacer()
}
.padding(.vertical, 4)
}
}
private struct ShareSheet: UIViewControllerRepresentable {
let items: [Any]
func makeUIViewController(context: Context) -> UIActivityViewController {
UIActivityViewController(activityItems: items, applicationActivities: nil)
}
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
}
private struct FileOperationHUD: View {
let operation: TimeSyncManager.FileOperationProgress
var body: some View {
ZStack {
Color.black.opacity(0.3)
.ignoresSafeArea()
VStack(spacing: 12) {
Text(operation.title)
.font(.headline)
Text(operation.message)
.font(.subheadline)
.multilineTextAlignment(.center)
.foregroundColor(.secondary)
if let progress = operation.progress {
ProgressView(value: progress)
.progressViewStyle(.linear)
.frame(maxWidth: 240)
Text("\(Int(progress * 100))%")
.font(.caption.monospacedDigit())
.foregroundColor(.secondary)
} else {
ProgressView()
.progressViewStyle(.circular)
}
}
.padding(24)
.background(.ultraThinMaterial)
.cornerRadius(16)
.shadow(radius: 10)
.padding()
}
.transition(.opacity)
}
}
private struct ConnectionOverlay: View {
let state: TimeSyncManager.ConnectionState
let statusMessage: String
let retryAction: () -> Void
private var showsSpinner: Bool {
switch state {
case .scanning, .connecting, .discovering:
return true
default:
return false
}
}
private var canRetry: Bool {
switch state {
case .failed, .idle:
return true
default:
return false
}
}
var body: some View {
ZStack {
Color.black.opacity(0.35)
.ignoresSafeArea()
VStack(spacing: 16) {
if showsSpinner {
ProgressView()
.progressViewStyle(.circular)
.scaleEffect(1.2)
} else {
Image(systemName: "exclamationmark.triangle")
.font(.system(size: 42, weight: .semibold))
.foregroundColor(.yellow)
}
Text(statusMessage)
.multilineTextAlignment(.center)
.font(.headline)
.foregroundColor(.primary)
if canRetry {
Button("Try Again", action: retryAction)
.buttonStyle(.borderedProminent)
}
}
.padding(28)
.frame(maxWidth: 320)
.background(.ultraThinMaterial)
.cornerRadius(20)
.shadow(radius: 12)
}
.transition(.opacity)
}
}
extension URL: Identifiable {
public var id: String { absoluteString }
}
#Preview {
ContentView()
.environmentObject(TimeSyncManager())

View File

@@ -1,6 +1,7 @@
import Combine
import CoreBluetooth
import Foundation
import UniformTypeIdentifiers
final class TimeSyncManager: NSObject, ObservableObject {
enum ConnectionState: String {
@@ -12,12 +13,82 @@ final class TimeSyncManager: NSObject, ObservableObject {
case failed = "Failed"
}
struct RemoteFileEntry: Identifiable, Hashable {
enum EntryType: Hashable {
case file(size: UInt32)
case directory
}
let path: String
let name: String
let type: EntryType
var id: String { path }
var isDirectory: Bool {
if case .directory = type {
return true
}
return false
}
var size: UInt32 {
if case let .file(size) = type {
return size
}
return 0
}
}
enum FileServiceError: Error, LocalizedError {
case characteristicUnavailable
case busy
case remoteError(code: Int, message: String?)
case invalidResponse
var errorDescription: String? {
switch self {
case .characteristicUnavailable:
return "File service is not ready yet."
case .busy:
return "Another file operation is already running."
case let .remoteError(code, message):
if let message, !message.isEmpty {
return "\(message) (code \(code))"
}
return "Remote error (code \(code))"
case .invalidResponse:
return "Received an invalid response from the device."
}
}
}
@Published private(set) var connectionState: ConnectionState = .idle
@Published private(set) var statusMessage: String = "Waiting for Bluetooth…"
@Published private(set) var lastSyncDate: Date?
private let serviceUUID = CBUUID(string: "00000001-CA7B-4EFD-B5A6-10C3F4D3F230")
private let characteristicUUID = CBUUID(string: "00000002-CA7B-4EFD-B5A6-10C3F4D3F231")
@Published private(set) var currentDirectory: String = "/"
@Published private(set) var directoryEntries: [RemoteFileEntry] = []
@Published private(set) var isFileBusy: Bool = false
@Published var fileErrorMessage: String?
@Published var downloadedFileURL: URL?
@Published private(set) var activeFileOperation: FileOperationProgress?
struct FileOperationProgress: Identifiable, Equatable {
let id: UUID
let title: String
let message: String
let progress: Double?
let indeterminate: Bool
}
private let timeServiceUUID = CBUUID(string: "00000001-CA7B-4EFD-B5A6-10C3F4D3F230")
private let timeCharacteristicUUID = CBUUID(string: "00000002-CA7B-4EFD-B5A6-10C3F4D3F231")
private let fileServiceUUID = CBUUID(string: "00000010-CA7B-4EFD-B5A6-10C3F4D3F230")
private let fileCommandUUID = CBUUID(string: "00000011-CA7B-4EFD-B5A6-10C3F4D3F231")
private let fileResponseUUID = CBUUID(string: "00000012-CA7B-4EFD-B5A6-10C3F4D3F232")
private let responseFlagComplete: UInt8 = 0x80
private lazy var central: CBCentralManager = CBCentralManager(
delegate: self,
@@ -30,25 +101,73 @@ final class TimeSyncManager: NSObject, ObservableObject {
private var targetPeripheral: CBPeripheral?
private var timeCharacteristic: CBCharacteristic?
private var fileCommandCharacteristic: CBCharacteristic?
private var fileResponseCharacteristic: CBCharacteristic?
private var retryWorkItem: DispatchWorkItem?
private var shouldKeepScanning = true
private var isScanning = false
private var fileCommandQueue: [Data] = []
private var isWritingFileCommand = false
private var pendingListData = Data()
private var pendingListPath: String?
private var pendingListOperationID: UUID?
private var simpleOperationID: UUID?
private var pendingDirectoryRequest: (path: String, operationID: UUID)?
private struct UploadState {
let id: UUID
var remotePath: String
var data: Data
var offset: Int = 0
var awaitingChunkAck: Bool = false
var completion: (Result<Void, Error>) -> Void
}
private var uploadState: UploadState?
private struct DownloadState {
let id: UUID
var remotePath: String
var expectedSize: Int?
var data = Data()
var completion: (Result<URL, Error>) -> Void
}
private var downloadState: DownloadState?
override init() {
super.init()
// Force central manager to initialise immediately so state updates arrive right away.
_ = central
if central.state == .poweredOn {
startScanning()
}
}
deinit {
retryWorkItem?.cancel()
}
// MARK: - Public BLE Controls
func forceRescan() {
statusMessage = "Restarting scan…"
shouldKeepScanning = true
retryWorkItem?.cancel()
let existingPeripheral = targetPeripheral
stopScanning()
if let existingPeripheral {
central.cancelPeripheralConnection(existingPeripheral)
}
targetPeripheral = nil
timeCharacteristic = nil
fileCommandCharacteristic = nil
fileResponseCharacteristic = nil
resetFileStateOnDisconnect()
startScanning()
}
@@ -75,19 +194,155 @@ final class TimeSyncManager: NSObject, ObservableObject {
payload.append(isDst)
payload.append(UInt8(0)) // Reserved byte
connectionState = .ready
statusMessage = "Sending current time…"
peripheral.writeValue(payload, for: characteristic, type: .withResponse)
lastSyncDate = now
connectionState = .ready
let timeString = DateFormatter.localizedString(from: now, dateStyle: .none, timeStyle: .medium)
statusMessage = "Time synced at \(timeString)."
}
// MARK: - File operations exposed to UI
func refreshDirectory() {
if isFileBusy {
pendingDirectoryRequest = (path: currentDirectory, operationID: pendingDirectoryRequest?.operationID ?? UUID())
return
}
requestDirectory(path: currentDirectory)
}
func enter(directory entry: RemoteFileEntry) {
guard entry.isDirectory else { return }
changeDirectory(to: NSString(string: currentDirectory).appendingPathComponent(entry.name))
}
func navigateUp() {
guard currentDirectory != "/" else { return }
changeDirectory(to: (currentDirectory as NSString).deletingLastPathComponent)
}
func changeDirectory(to path: String) {
let normalized = normalizedPath(path)
currentDirectory = normalized
requestDirectory(path: normalized)
}
func uploadFile(from url: URL, suggestedName: String? = nil, completion: @escaping (Result<Void, Error>) -> Void) {
guard uploadState == nil else {
completion(.failure(FileServiceError.busy))
return
}
guard let commandCharacteristic = fileCommandCharacteristic else {
completion(.failure(FileServiceError.characteristicUnavailable))
return
}
let fileData: Data
let shouldStopAccessing = url.startAccessingSecurityScopedResource()
defer {
if shouldStopAccessing {
url.stopAccessingSecurityScopedResource()
}
}
do {
fileData = try Data(contentsOf: url)
} catch {
completion(.failure(error))
return
}
let name = suggestedName ?? url.lastPathComponent
let remotePath = normalizedPath(NSString(string: currentDirectory).appendingPathComponent(name))
let opID = UUID()
uploadState = UploadState(id: opID, remotePath: remotePath, data: fileData, completion: completion)
isFileBusy = true
updateOperation(id: opID, title: "Uploading", message: remotePath, progress: 0.0)
let payload = payloadFor(path: remotePath, extra: UInt32(fileData.count).littleEndianBytes)
enqueueFileCommand(.uploadBegin, payload: payload, characteristic: commandCharacteristic)
}
func delete(entry: RemoteFileEntry) {
let opcode: FileCommand = entry.isDirectory ? .deleteDirectory : .deleteFile
guard let commandCharacteristic = fileCommandCharacteristic else {
fileErrorMessage = FileServiceError.characteristicUnavailable.localizedDescription
return
}
let payload = payloadFor(path: entry.path)
isFileBusy = true
let opID = UUID()
let title = entry.isDirectory ? "Deleting Folder" : "Deleting File"
updateOperation(id: opID, title: title, message: entry.path, progress: nil, indeterminate: true)
simpleOperationID = opID
enqueueFileCommand(opcode, payload: payload, characteristic: commandCharacteristic)
}
func createDirectory(named name: String) {
guard let commandCharacteristic = fileCommandCharacteristic else {
fileErrorMessage = FileServiceError.characteristicUnavailable.localizedDescription
return
}
let remotePath = normalizedPath(NSString(string: currentDirectory).appendingPathComponent(name))
let payload = payloadFor(path: remotePath)
isFileBusy = true
let opID = UUID()
updateOperation(id: opID, title: "Creating Folder", message: remotePath, progress: nil, indeterminate: true)
simpleOperationID = opID
enqueueFileCommand(.createDirectory, payload: payload, characteristic: commandCharacteristic)
}
func rename(entry: RemoteFileEntry, to newName: String) {
guard let commandCharacteristic = fileCommandCharacteristic else {
fileErrorMessage = FileServiceError.characteristicUnavailable.localizedDescription
return
}
let srcPath = entry.path
let dstPath = normalizedPath(NSString(string: (srcPath as NSString).deletingLastPathComponent).appendingPathComponent(newName))
var payload = Data()
payload.appendPath(srcPath)
payload.appendPath(dstPath)
isFileBusy = true
let opID = UUID()
updateOperation(id: opID, title: "Renaming", message: srcPath, progress: nil, indeterminate: true)
simpleOperationID = opID
enqueueFileCommand(.renamePath, payload: payload, characteristic: commandCharacteristic)
}
func download(entry: RemoteFileEntry, completion: @escaping (Result<URL, Error>) -> Void) {
guard !entry.isDirectory else {
completion(.failure(FileServiceError.busy))
return
}
guard downloadState == nil else {
completion(.failure(FileServiceError.busy))
return
}
guard let commandCharacteristic = fileCommandCharacteristic else {
completion(.failure(FileServiceError.characteristicUnavailable))
return
}
let opID = UUID()
downloadState = DownloadState(id: opID, remotePath: entry.path, expectedSize: nil, data: Data(), completion: completion)
isFileBusy = true
updateOperation(id: opID, title: "Downloading", message: entry.path, progress: nil, indeterminate: true)
let payload = payloadFor(path: entry.path)
enqueueFileCommand(.downloadRequest, payload: payload, characteristic: commandCharacteristic)
}
// MARK: - Private helpers
private func startScanning() {
guard shouldKeepScanning, central.state == .poweredOn else { return }
if isScanning { return }
central.scanForPeripherals(withServices: [serviceUUID], options: [
central.scanForPeripherals(withServices: [timeServiceUUID, fileServiceUUID], options: [
CBCentralManagerScanOptionAllowDuplicatesKey: false
])
isScanning = true
connectionState = .scanning
statusMessage = "Scanning for Cardboy…"
@@ -108,8 +363,372 @@ final class TimeSyncManager: NSObject, ObservableObject {
retryWorkItem = workItem
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem)
}
private enum FileCommand: UInt8 {
case listDirectory = 0x01
case uploadBegin = 0x02
case uploadChunk = 0x03
case uploadEnd = 0x04
case downloadRequest = 0x05
case deleteFile = 0x06
case createDirectory = 0x07
case deleteDirectory = 0x08
case renamePath = 0x09
}
private func enqueueFileCommand(_ opcode: FileCommand, payload: Data, characteristic: CBCharacteristic) {
var packet = Data()
packet.append(opcode.rawValue)
packet.append(UInt8(0))
packet.append(UInt8(payload.count & 0xFF))
packet.append(UInt8((payload.count >> 8) & 0xFF))
packet.append(payload)
fileCommandQueue.append(packet)
processFileCommandQueue(characteristic: characteristic)
}
private func processFileCommandQueue(characteristic: CBCharacteristic? = nil) {
guard !isWritingFileCommand else { return }
guard let characteristic = characteristic ?? fileCommandCharacteristic else { return }
guard !fileCommandQueue.isEmpty, let peripheral = targetPeripheral else { return }
let packet = fileCommandQueue.removeFirst()
isWritingFileCommand = true
peripheral.writeValue(packet, for: characteristic, type: .withResponse)
}
private func resetFileStateOnDisconnect() {
fileCommandQueue.removeAll()
isWritingFileCommand = false
uploadState = nil
downloadState = nil
pendingListData.removeAll()
pendingListPath = nil
pendingListOperationID = nil
simpleOperationID = nil
pendingDirectoryRequest = nil
isFileBusy = false
currentDirectory = "/"
directoryEntries = []
clearOperation()
}
private func payloadFor(path: String, extra: [UInt8]? = nil) -> Data {
var data = Data()
data.appendPath(path)
if let extra {
data.append(contentsOf: extra)
}
return data
}
private func updateOperation(id: UUID, title: String, message: String, progress: Double?, indeterminate: Bool = false) {
DispatchQueue.main.async {
self.activeFileOperation = FileOperationProgress(id: id, title: title, message: message, progress: progress, indeterminate: indeterminate)
}
}
private func clearOperation(ifMatches id: UUID? = nil) {
DispatchQueue.main.async {
if let id, self.activeFileOperation?.id != id {
return
}
self.activeFileOperation = nil
}
}
private func normalizedPath(_ path: String) -> String {
var normalized = path
if normalized.isEmpty {
normalized = "/"
}
if !normalized.hasPrefix("/") {
normalized = "/" + normalized
}
if normalized.count > 1 && normalized.hasSuffix("/") {
normalized.removeLast()
}
return normalized
}
private func handleFileResponse(_ data: Data) {
guard data.count >= 4 else {
fileErrorMessage = FileServiceError.invalidResponse.localizedDescription
return
}
let opcodeRaw = data[0]
let status = data[1]
let length = Int(data[2]) | (Int(data[3]) << 8)
guard data.count >= 4 + length else {
fileErrorMessage = FileServiceError.invalidResponse.localizedDescription
return
}
let payload = data.subdata(in: 4 ..< 4 + length)
let isComplete = (status & responseFlagComplete) != 0
let errorCode = Int(status & ~responseFlagComplete)
if errorCode != 0 {
let message = String(data: payload, encoding: .utf8)
handleFileError(opcodeRaw, code: errorCode, message: message)
return
}
guard let opcode = FileCommand(rawValue: opcodeRaw) else { return }
switch opcode {
case .listDirectory:
pendingListData.append(payload)
if isComplete {
let opID = pendingListOperationID
finalizeDirectoryListing(path: pendingListPath ?? currentDirectory, data: pendingListData)
pendingListData.removeAll()
pendingListPath = nil
isFileBusy = false
pendingListOperationID = nil
clearOperation(ifMatches: opID)
}
case .uploadBegin:
if isComplete {
sendNextUploadChunk()
}
case .uploadChunk:
if isComplete {
uploadState?.awaitingChunkAck = false
sendNextUploadChunk()
}
case .uploadEnd:
if isComplete {
let opID = uploadState?.id
let completion = uploadState?.completion
uploadState = nil
isFileBusy = false
completion?(.success(()))
refreshDirectory()
clearOperation(ifMatches: opID)
}
case .downloadRequest:
handleDownloadResponse(payload: payload, isComplete: isComplete)
case .deleteFile, .createDirectory, .deleteDirectory, .renamePath:
if isComplete {
isFileBusy = false
refreshDirectory()
let opID = simpleOperationID
simpleOperationID = nil
clearOperation(ifMatches: opID)
}
}
}
private func handleFileError(_ opcodeRaw: UInt8, code: Int, message: String?) {
var operationID: UUID?
defer {
isFileBusy = false
clearOperation(ifMatches: operationID)
}
let error = FileServiceError.remoteError(code: code, message: message)
fileErrorMessage = error.localizedDescription
pendingListData.removeAll()
pendingListPath = nil
pendingListOperationID = nil
switch FileCommand(rawValue: opcodeRaw) {
case .listDirectory:
operationID = pendingListOperationID
pendingListOperationID = nil
case .uploadBegin, .uploadChunk, .uploadEnd:
operationID = uploadState?.id
if let completion = uploadState?.completion {
completion(.failure(error))
}
uploadState = nil
case .downloadRequest:
operationID = downloadState?.id
if let completion = downloadState?.completion {
completion(.failure(error))
}
downloadState = nil
case .deleteFile, .createDirectory, .deleteDirectory, .renamePath:
operationID = simpleOperationID
simpleOperationID = nil
default:
break
}
}
private func finalizeDirectoryListing(path: String, data: Data) {
var entries: [RemoteFileEntry] = []
var index = data.startIndex
while index + 8 <= data.endIndex {
let typeRaw = data[index]
index += 1 // type
index += 1 // reserved
let nameLen = Int(data[index]) | (Int(data[index + 1]) << 8)
index += 2
let size = UInt32(data[index]) |
(UInt32(data[index + 1]) << 8) |
(UInt32(data[index + 2]) << 16) |
(UInt32(data[index + 3]) << 24)
index += 4
guard index + nameLen <= data.endIndex else { break }
let nameData = data[index ..< index + nameLen]
index += nameLen
guard let name = String(data: nameData, encoding: .utf8) else { continue }
let fullPath = normalizedPath(NSString(string: path).appendingPathComponent(name))
let type: RemoteFileEntry.EntryType = (typeRaw == 1) ? .directory : .file(size: size)
entries.append(RemoteFileEntry(path: fullPath, name: name, type: type))
}
directoryEntries = entries.sorted {
if $0.isDirectory == $1.isDirectory {
return $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending
}
return $0.isDirectory && !$1.isDirectory
}
}
private func sendNextUploadChunk() {
guard var state = uploadState else { return }
guard let commandCharacteristic = fileCommandCharacteristic,
targetPeripheral != nil else { return }
if state.awaitingChunkAck {
uploadState = state
return
}
if state.offset >= state.data.count {
updateOperation(id: state.id, title: "Uploading", message: state.remotePath, progress: 1.0)
print("Upload progress \(state.remotePath): 100%")
uploadState = state
enqueueFileCommand(.uploadEnd, payload: Data(), characteristic: commandCharacteristic)
return
}
let chunkSize = min(140, state.data.count - state.offset)
let chunk = state.data[state.offset ..< state.offset + chunkSize]
state.offset += chunkSize
state.awaitingChunkAck = true
let totalBytes = max(state.data.count, 1)
let progress = Double(state.offset) / Double(totalBytes)
updateOperation(id: state.id, title: "Uploading", message: state.remotePath, progress: progress)
print("Upload progress \(state.remotePath): \(Int(progress * 100))%")
uploadState = state
enqueueFileCommand(.uploadChunk, payload: Data(chunk), characteristic: commandCharacteristic)
processFileCommandQueue(characteristic: commandCharacteristic)
}
private func handleDownloadResponse(payload: Data, isComplete: Bool) {
guard var state = downloadState else { return }
if state.expectedSize == nil && payload.count == 4 {
let size = Int(payload[0]) | (Int(payload[1]) << 8) | (Int(payload[2]) << 16) | (Int(payload[3]) << 24)
state.expectedSize = size
downloadState = state
if size == 0 {
updateOperation(id: state.id, title: "Downloading", message: state.remotePath, progress: 1.0)
} else {
updateOperation(id: state.id, title: "Downloading", message: state.remotePath, progress: 0.0)
}
if isComplete && size == 0 {
finishDownload(state: state)
}
return
}
state.data.append(payload)
downloadState = state
if let expected = state.expectedSize, expected > 0 {
let progress = min(Double(state.data.count) / Double(expected), 1.0)
updateOperation(id: state.id, title: "Downloading", message: state.remotePath, progress: progress)
print("Download progress \(state.remotePath): \(Int(progress * 100))%")
} else {
updateOperation(id: state.id, title: "Downloading", message: state.remotePath, progress: nil, indeterminate: true)
}
if isComplete {
finishDownload(state: state)
}
}
private func finishDownload(state: DownloadState) {
var completedState = state
defer {
downloadState = nil
isFileBusy = false
clearOperation(ifMatches: completedState.id)
}
updateOperation(id: completedState.id, title: "Download Complete", message: completedState.remotePath, progress: 1.0)
print("Download progress \(completedState.remotePath): 100%")
let filename = NSString(string: completedState.remotePath).lastPathComponent
let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(filename)
do {
try completedState.data.write(to: tempURL, options: .atomic)
completedState.completion(.success(tempURL))
downloadedFileURL = tempURL
} catch {
completedState.completion(.failure(error))
}
}
private func requestDirectory(path: String) {
let normalizedPath = normalizedPath(path)
if let commandCharacteristic = fileCommandCharacteristic {
let opID: UUID
if let pending = pendingDirectoryRequest, pending.path == normalizedPath {
opID = pending.operationID
pendingDirectoryRequest = nil
} else {
opID = UUID()
}
startDirectoryRequest(path: normalizedPath, operationID: opID, characteristic: commandCharacteristic)
} else {
let opID = pendingDirectoryRequest?.operationID ?? UUID()
pendingDirectoryRequest = (path: normalizedPath, operationID: opID)
if connectionState == .ready {
pendingListPath = normalizedPath
pendingListData.removeAll()
isFileBusy = true
pendingListOperationID = opID
updateOperation(id: opID, title: "Loading", message: normalizedPath, progress: nil, indeterminate: true)
}
}
}
@discardableResult
private func flushPendingDirectoryRequest() -> Bool {
guard let pending = pendingDirectoryRequest,
let commandCharacteristic = fileCommandCharacteristic else { return false }
pendingDirectoryRequest = nil
startDirectoryRequest(path: pending.path, operationID: pending.operationID, characteristic: commandCharacteristic)
return true
}
private func startDirectoryRequest(path: String, operationID: UUID, characteristic: CBCharacteristic) {
pendingListPath = path
pendingListData.removeAll()
isFileBusy = true
pendingListOperationID = operationID
updateOperation(id: operationID, title: "Loading", message: path, progress: nil, indeterminate: true)
let payload = payloadFor(path: path)
enqueueFileCommand(.listDirectory, payload: payload, characteristic: characteristic)
}
}
// MARK: - CBCentralManagerDelegate
extension TimeSyncManager: CBCentralManagerDelegate {
func centralManager(_ central: CBCentralManager, willRestoreState dict: [String: Any]) {
shouldKeepScanning = true
@@ -151,8 +770,8 @@ extension TimeSyncManager: CBCentralManagerDelegate {
}
}
func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any],
rssi RSSI: NSNumber) {
func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral,
advertisementData: [String: Any], rssi RSSI: NSNumber) {
statusMessage = "Found \(peripheral.name ?? "device"), connecting…"
connectionState = .connecting
shouldKeepScanning = false
@@ -165,27 +784,31 @@ extension TimeSyncManager: CBCentralManagerDelegate {
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
statusMessage = "Connected. Discovering services…"
connectionState = .discovering
peripheral.discoverServices([serviceUUID])
peripheral.discoverServices([timeServiceUUID, fileServiceUUID])
}
func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) {
statusMessage = "Failed to connect. \(error?.localizedDescription ?? "")"
connectionState = .failed
targetPeripheral = nil
timeCharacteristic = nil
resetFileStateOnDisconnect()
scheduleRetry()
}
func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
let reason = error?.localizedDescription ?? "Disconnected."
statusMessage = reason
statusMessage = "Disconnected. \(error?.localizedDescription ?? "")"
connectionState = .idle
targetPeripheral = nil
timeCharacteristic = nil
fileCommandCharacteristic = nil
fileResponseCharacteristic = nil
resetFileStateOnDisconnect()
scheduleRetry()
}
}
// MARK: - CBPeripheralDelegate
extension TimeSyncManager: CBPeripheralDelegate {
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
if let error {
@@ -202,8 +825,12 @@ extension TimeSyncManager: CBPeripheralDelegate {
return
}
for service in services where service.uuid == serviceUUID {
peripheral.discoverCharacteristics([characteristicUUID], for: service)
for service in services {
if service.uuid == timeServiceUUID {
peripheral.discoverCharacteristics([timeCharacteristicUUID], for: service)
} else if service.uuid == fileServiceUUID {
peripheral.discoverCharacteristics([fileCommandUUID, fileResponseUUID], for: service)
}
}
}
@@ -215,39 +842,105 @@ extension TimeSyncManager: CBPeripheralDelegate {
return
}
guard let characteristics = service.characteristics,
let targetCharacteristic = characteristics.first(where: { $0.uuid == characteristicUUID }) else {
statusMessage = "Time sync characteristic missing."
connectionState = .failed
scheduleRetry()
guard let characteristics = service.characteristics else { return }
for characteristic in characteristics {
if characteristic.uuid == timeCharacteristicUUID {
timeCharacteristic = characteristic
sendCurrentTime()
} else if characteristic.uuid == fileCommandUUID {
fileCommandCharacteristic = characteristic
processFileCommandQueue(characteristic: characteristic)
} else if characteristic.uuid == fileResponseUUID {
fileResponseCharacteristic = characteristic
peripheral.setNotifyValue(true, for: characteristic)
}
}
if timeCharacteristic != nil && fileResponseCharacteristic != nil && connectionState == .discovering {
connectionState = .ready
statusMessage = "Connected to Cardboy."
}
if fileResponseCharacteristic?.isNotifying == true {
_ = flushPendingDirectoryRequest()
}
}
func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) {
if let error {
fileErrorMessage = error.localizedDescription
return
}
timeCharacteristic = targetCharacteristic
connectionState = .ready
statusMessage = "Ready to sync time."
sendCurrentTime()
if characteristic.uuid == fileResponseUUID, characteristic.isNotifying {
if !flushPendingDirectoryRequest() {
refreshDirectory()
}
}
}
func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
if let error {
fileErrorMessage = error.localizedDescription
isFileBusy = false
return
}
guard let value = characteristic.value else { return }
if characteristic.uuid == fileResponseUUID {
handleFileResponse(value)
}
}
func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) {
if characteristic.uuid == fileCommandUUID {
if let error {
statusMessage = "Write failed: \(error.localizedDescription)"
connectionState = .failed
scheduleRetry()
return
fileErrorMessage = error.localizedDescription
}
lastSyncDate = Date()
if let date = lastSyncDate {
let formatted = DateFormatter.localizedString(from: date, dateStyle: .none, timeStyle: .medium)
statusMessage = "Time synced at \(formatted)."
} else {
statusMessage = "Time synced."
isWritingFileCommand = false
processFileCommandQueue(characteristic: characteristic)
}
connectionState = .ready
shouldKeepScanning = true
central.cancelPeripheralConnection(peripheral)
}
}
// MARK: - Data helpers
private extension Data {
mutating func appendPath(_ path: String) {
let normalized: String
if path.isEmpty {
normalized = "/"
} else if path.hasPrefix("/") {
normalized = path
} else {
normalized = "/" + path
}
let pathData = normalized.data(using: .utf8) ?? Data()
append(contentsOf: UInt16(pathData.count).littleEndianBytes)
append(pathData)
}
}
private extension UInt16 {
var littleEndianBytes: [UInt8] {
let value = self.littleEndian
return [
UInt8(value & 0xFF),
UInt8((value >> 8) & 0xFF),
]
}
}
private extension UInt32 {
var littleEndianBytes: [UInt8] {
let value = self.littleEndian
return [
UInt8(value & 0xFF),
UInt8((value >> 8) & 0xFF),
UInt8((value >> 16) & 0xFF),
UInt8((value >> 24) & 0xFF),
]
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,7 @@
#include "cardboy/apps/clock_app.hpp"
#include "cardboy/apps/gameboy_app.hpp"
#include "cardboy/apps/lockscreen_app.hpp"
#include "cardboy/apps/menu_app.hpp"
#include "cardboy/apps/settings_app.hpp"
#include "cardboy/apps/snake_app.hpp"
@@ -233,6 +234,7 @@ extern "C" void app_main() {
#endif
system.registerApp(apps::createMenuAppFactory());
system.registerApp(apps::createLockscreenAppFactory());
system.registerApp(apps::createSettingsAppFactory());
system.registerApp(apps::createClockAppFactory());
system.registerApp(apps::createSnakeAppFactory());

View File

@@ -13,6 +13,7 @@ target_link_libraries(cardboy_apps
target_compile_features(cardboy_apps PUBLIC cxx_std_20)
add_subdirectory(menu)
add_subdirectory(lockscreen)
add_subdirectory(clock)
add_subdirectory(settings)
add_subdirectory(gameboy)

View File

@@ -0,0 +1,10 @@
target_sources(cardboy_apps
PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/src/lockscreen_app.cpp
)
target_include_directories(cardboy_apps
PUBLIC
${CMAKE_CURRENT_SOURCE_DIR}/include
)

View File

@@ -0,0 +1,16 @@
#pragma once
#include "cardboy/sdk/app_framework.hpp"
#include <memory>
#include <string_view>
namespace apps {
inline constexpr char kLockscreenAppName[] = "Lockscreen";
inline constexpr std::string_view kLockscreenAppNameView = kLockscreenAppName;
std::unique_ptr<cardboy::sdk::IAppFactory> createLockscreenAppFactory();
} // namespace apps

View File

@@ -0,0 +1,271 @@
#include "cardboy/apps/lockscreen_app.hpp"
#include "cardboy/apps/menu_app.hpp"
#include "cardboy/gfx/font16x8.hpp"
#include "cardboy/sdk/app_framework.hpp"
#include "cardboy/sdk/app_system.hpp"
#include <algorithm>
#include <cstdio>
#include <ctime>
#include <string>
#include <string_view>
namespace apps {
namespace {
using cardboy::sdk::AppContext;
constexpr std::uint32_t kRefreshIntervalMs = 100;
constexpr std::uint32_t kUnlockHoldMs = 1500;
using Framebuffer = typename AppContext::Framebuffer;
using Clock = typename AppContext::Clock;
struct TimeSnapshot {
bool hasWallTime = false;
int hour24 = 0;
int minute = 0;
int second = 0;
int year = 0;
int month = 0;
int day = 0;
int weekday = 0;
std::uint64_t uptimeSeconds = 0;
};
class LockscreenApp final : public cardboy::sdk::IApp {
public:
explicit LockscreenApp(AppContext& ctx) : context(ctx), framebuffer(ctx.framebuffer), clock(ctx.clock) {}
void onStart() override {
cancelRefreshTimer();
lastSnapshot = {};
holdActive = false;
holdProgressMs = 0;
dirty = true;
const auto snap = captureTime();
renderIfNeeded(snap);
lastSnapshot = snap;
refreshTimer = context.scheduleRepeatingTimer(kRefreshIntervalMs);
}
void onStop() override { cancelRefreshTimer(); }
void handleEvent(const cardboy::sdk::AppEvent& event) override {
switch (event.type) {
case cardboy::sdk::AppEventType::Button:
handleButtonEvent(event.button);
break;
case cardboy::sdk::AppEventType::Timer:
if (event.timer.handle == refreshTimer) {
advanceHoldProgress();
updateDisplay();
}
break;
}
}
private:
AppContext& context;
Framebuffer& framebuffer;
Clock& clock;
bool dirty = false;
cardboy::sdk::AppTimerHandle refreshTimer = cardboy::sdk::kInvalidAppTimer;
TimeSnapshot lastSnapshot{};
bool holdActive = false;
std::uint32_t holdProgressMs = 0;
void cancelRefreshTimer() {
if (refreshTimer != cardboy::sdk::kInvalidAppTimer) {
context.cancelTimer(refreshTimer);
refreshTimer = cardboy::sdk::kInvalidAppTimer;
}
}
static bool comboPressed(const cardboy::sdk::InputState& state) { return state.a && state.select; }
void handleButtonEvent(const cardboy::sdk::AppButtonEvent& button) {
const bool comboNow = comboPressed(button.current);
updateHoldState(comboNow);
updateDisplay();
}
void updateHoldState(bool comboNow) {
if (comboNow) {
if (!holdActive) {
holdActive = true;
dirty = true;
}
} else {
if (holdActive || holdProgressMs != 0) {
holdActive = false;
holdProgressMs = 0;
dirty = true;
}
}
}
void advanceHoldProgress() {
if (holdActive) {
const std::uint32_t next =
std::min<std::uint32_t>(holdProgressMs + kRefreshIntervalMs, kUnlockHoldMs);
if (next != holdProgressMs) {
holdProgressMs = next;
dirty = true;
}
if (holdProgressMs >= kUnlockHoldMs) {
holdActive = false;
context.requestAppSwitchByName(kMenuAppName);
}
} else if (holdProgressMs != 0) {
holdProgressMs = 0;
dirty = true;
}
}
void updateDisplay() {
const auto snap = captureTime();
if (!sameSnapshot(snap, lastSnapshot))
dirty = true;
renderIfNeeded(snap);
lastSnapshot = snap;
}
static bool sameSnapshot(const TimeSnapshot& a, const TimeSnapshot& b) {
return a.hasWallTime == b.hasWallTime && a.hour24 == b.hour24 && a.minute == b.minute &&
a.second == b.second && a.day == b.day && a.month == b.month && a.year == b.year;
}
TimeSnapshot captureTime() const {
TimeSnapshot snap{};
snap.uptimeSeconds = clock.millis() / 1000ULL;
std::time_t raw = 0;
if (std::time(&raw) != static_cast<std::time_t>(-1) && raw > 0) {
std::tm tm{};
if (localtime_r(&raw, &tm) != nullptr) {
snap.hasWallTime = true;
snap.hour24 = tm.tm_hour;
snap.minute = tm.tm_min;
snap.second = tm.tm_sec;
snap.year = tm.tm_year + 1900;
snap.month = tm.tm_mon + 1;
snap.day = tm.tm_mday;
snap.weekday = tm.tm_wday;
return snap;
}
}
snap.hasWallTime = false;
snap.hour24 = static_cast<int>((snap.uptimeSeconds / 3600ULL) % 24ULL);
snap.minute = static_cast<int>((snap.uptimeSeconds / 60ULL) % 60ULL);
snap.second = static_cast<int>(snap.uptimeSeconds % 60ULL);
return snap;
}
static void drawCenteredText(Framebuffer& fb, int y, std::string_view text, int scale, int letterSpacing = 0) {
const int width = font16x8::measureText(text, scale, letterSpacing);
const int x = (fb.width() - width) / 2;
font16x8::drawText(fb, x, y, text, scale, true, letterSpacing);
}
static void drawRectOutline(Framebuffer& fb, int x, int y, int w, int h) {
if (w <= 0 || h <= 0)
return;
for (int dx = 0; dx < w; ++dx) {
fb.drawPixel(x + dx, y, true);
fb.drawPixel(x + dx, y + h - 1, true);
}
for (int dy = 0; dy < h; ++dy) {
fb.drawPixel(x, y + dy, true);
fb.drawPixel(x + w - 1, y + dy, true);
}
}
static void fillRect(Framebuffer& fb, int x, int y, int w, int h) {
if (w <= 0 || h <= 0)
return;
for (int dy = 0; dy < h; ++dy) {
for (int dx = 0; dx < w; ++dx) {
fb.drawPixel(x + dx, y + dy, true);
}
}
}
static std::string formatDate(const TimeSnapshot& snap) {
static const char* kWeekdays[] = {"SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"};
if (!snap.hasWallTime)
return "UPTIME MODE";
const char* weekday = (snap.weekday >= 0 && snap.weekday < 7) ? kWeekdays[snap.weekday] : "";
char buffer[32];
std::snprintf(buffer, sizeof(buffer), "%s %04d-%02d-%02d", weekday, snap.year, snap.month, snap.day);
return buffer;
}
void renderIfNeeded(const TimeSnapshot& snap) {
if (!dirty)
return;
dirty = false;
framebuffer.frameReady();
const int scaleTime = 4;
const int scaleSeconds = 2;
const int scaleSmall = 1;
char hoursMinutes[6];
std::snprintf(hoursMinutes, sizeof(hoursMinutes), "%02d:%02d", snap.hour24, snap.minute);
const int mainW = font16x8::measureText(hoursMinutes, scaleTime, 0);
const int timeY = (framebuffer.height() - font16x8::kGlyphHeight * scaleTime) / 2 - 8;
const int timeX = (framebuffer.width() - mainW) / 2;
const int secX = timeX + mainW + 12;
const int secY = timeY + font16x8::kGlyphHeight * scaleTime - font16x8::kGlyphHeight * scaleSeconds;
char secs[3];
std::snprintf(secs, sizeof(secs), "%02d", snap.second);
font16x8::drawText(framebuffer, timeX, timeY, hoursMinutes, scaleTime, true, 0);
font16x8::drawText(framebuffer, secX, secY, secs, scaleSeconds, true, 0);
const std::string dateLine = formatDate(snap);
drawCenteredText(framebuffer, timeY + font16x8::kGlyphHeight * scaleTime + 24, dateLine, scaleSmall, 1);
const char* instruction = holdActive ? "KEEP HOLDING A+SELECT" : "HOLD A+SELECT";
drawCenteredText(framebuffer, framebuffer.height() - 52, instruction, scaleSmall, 1);
if (holdActive || holdProgressMs > 0) {
const int barWidth = framebuffer.width() - 64;
const int barHeight = 14;
const int barX = (framebuffer.width() - barWidth) / 2;
const int barY = framebuffer.height() - 32;
const int innerWidth = barWidth - 2;
const int innerHeight = barHeight - 2;
drawRectOutline(framebuffer, barX, barY, barWidth, barHeight);
const float ratio =
std::clamp(holdProgressMs / static_cast<float>(kUnlockHoldMs), 0.0f, 1.0f);
const int fillWidth = static_cast<int>(ratio * innerWidth + 0.5f);
if (fillWidth > 0)
fillRect(framebuffer, barX + 1, barY + 1, fillWidth, innerHeight);
}
framebuffer.sendFrame();
}
};
class LockscreenAppFactory final : public cardboy::sdk::IAppFactory {
public:
const char* name() const override { return kLockscreenAppName; }
std::unique_ptr<cardboy::sdk::IApp> create(cardboy::sdk::AppContext& context) override {
return std::make_unique<LockscreenApp>(context);
}
};
} // namespace
std::unique_ptr<cardboy::sdk::IAppFactory> createLockscreenAppFactory() {
return std::make_unique<LockscreenAppFactory>();
}
} // namespace apps

View File

@@ -1,4 +1,5 @@
#include "cardboy/apps/menu_app.hpp"
#include "cardboy/apps/lockscreen_app.hpp"
#include "cardboy/sdk/app_framework.hpp"
#include "cardboy/sdk/app_system.hpp"
@@ -6,6 +7,7 @@
#include "cardboy/gfx/font16x8.hpp"
#include <algorithm>
#include <cstdint>
#include <cstdlib>
#include <string>
#include <string_view>
@@ -19,6 +21,8 @@ using cardboy::sdk::AppContext;
using Framebuffer = typename AppContext::Framebuffer;
constexpr std::uint32_t kIdleTimeoutMs = 15000;
struct MenuEntry {
std::string name;
std::size_t index = 0;
@@ -31,15 +35,46 @@ public:
void onStart() override {
refreshEntries();
dirty = true;
resetInactivityTimer();
renderIfNeeded();
}
void handleEvent(const cardboy::sdk::AppEvent& event) override {
if (event.type != cardboy::sdk::AppEventType::Button)
return;
void onStop() override { cancelInactivityTimer(); }
const auto& current = event.button.current;
const auto& previous = event.button.previous;
void handleEvent(const cardboy::sdk::AppEvent& event) override {
switch (event.type) {
case cardboy::sdk::AppEventType::Button:
handleButtonEvent(event.button);
break;
case cardboy::sdk::AppEventType::Timer:
if (event.timer.handle == inactivityTimer) {
cancelInactivityTimer();
context.requestAppSwitchByName(kLockscreenAppName);
}
break;
}
}
private:
AppContext& context;
Framebuffer& framebuffer;
std::vector<MenuEntry> entries;
std::size_t selected = 0;
bool dirty = false;
cardboy::sdk::AppTimerHandle inactivityTimer = cardboy::sdk::kInvalidAppTimer;
void handleButtonEvent(const cardboy::sdk::AppButtonEvent& button) {
resetInactivityTimer();
const auto& current = button.current;
const auto& previous = button.previous;
if (current.b && !previous.b) {
context.requestAppSwitchByName(kLockscreenAppName);
return;
}
if (current.left && !previous.left) {
moveSelection(-1);
@@ -54,14 +89,6 @@ public:
renderIfNeeded();
}
private:
AppContext& context;
Framebuffer& framebuffer;
std::vector<MenuEntry> entries;
std::size_t selected = 0;
bool dirty = false;
void moveSelection(int step) {
if (entries.empty())
return;
@@ -93,7 +120,8 @@ private:
const char* name = factory->name();
if (!name)
continue;
if (std::string_view(name) == kMenuAppNameView)
const std::string_view appName(name);
if (appName == kMenuAppNameView || appName == kLockscreenAppNameView)
continue;
entries.push_back(MenuEntry{std::string(name), i});
}
@@ -159,6 +187,18 @@ private:
framebuffer.sendFrame();
}
void cancelInactivityTimer() {
if (inactivityTimer != cardboy::sdk::kInvalidAppTimer) {
context.cancelTimer(inactivityTimer);
inactivityTimer = cardboy::sdk::kInvalidAppTimer;
}
}
void resetInactivityTimer() {
cancelInactivityTimer();
inactivityTimer = context.scheduleTimer(kIdleTimeoutMs);
}
};
class MenuAppFactory final : public cardboy::sdk::IAppFactory {

View File

@@ -1,6 +1,7 @@
#include "cardboy/apps/clock_app.hpp"
#include "cardboy/apps/gameboy_app.hpp"
#include "cardboy/apps/menu_app.hpp"
#include "cardboy/apps/lockscreen_app.hpp"
#include "cardboy/apps/settings_app.hpp"
#include "cardboy/apps/snake_app.hpp"
#include "cardboy/apps/tetris_app.hpp"
@@ -27,6 +28,7 @@ int main() {
buzzer->setMuted(persistentSettings.mute);
system.registerApp(apps::createMenuAppFactory());
system.registerApp(apps::createLockscreenAppFactory());
system.registerApp(apps::createSettingsAppFactory());
system.registerApp(apps::createClockAppFactory());
system.registerApp(apps::createGameboyAppFactory());