mirror of
https://github.com/usatiuk/cardboy.git
synced 2025-10-28 23:27:49 +01:00
Compare commits
11 Commits
eeedc629d7
...
e8ae1cbec4
| Author | SHA1 | Date | |
|---|---|---|---|
| e8ae1cbec4 | |||
| b72ea4f417 | |||
| bf0ffe8632 | |||
| 96bfaaf64b | |||
| cf5a848741 | |||
| 7c492627f0 | |||
| be2629a008 | |||
| 016629eb82 | |||
| de1ac0e7a2 | |||
| 3ab2a7bf26 | |||
| b4f11851d7 |
@@ -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).
|
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).
|
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.
|
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 self‑contained and can be reused.
|
Optionally bundle this code into your existing app—`TimeSyncManager` is self‑contained and can be reused.
|
||||||
|
|||||||
@@ -268,6 +268,7 @@
|
|||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = "cardboy-companion/Info.plist";
|
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_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_NSBluetoothPeripheralUsageDescription = "Cardboy Companion needs Bluetooth to sync time with your handheld.";
|
||||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
@@ -304,6 +305,7 @@
|
|||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = "cardboy-companion/Info.plist";
|
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_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_NSBluetoothPeripheralUsageDescription = "Cardboy Companion needs Bluetooth to sync time with your handheld.";
|
||||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"images" : [
|
"images" : [
|
||||||
{
|
{
|
||||||
|
"filename" : "cardboy-icon.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"platform" : "ios",
|
"platform" : "ios",
|
||||||
"size" : "1024x1024"
|
"size" : "1024x1024"
|
||||||
@@ -12,6 +13,7 @@
|
|||||||
"value" : "dark"
|
"value" : "dark"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"filename" : "cardboy-icon-dark.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"platform" : "ios",
|
"platform" : "ios",
|
||||||
"size" : "1024x1024"
|
"size" : "1024x1024"
|
||||||
@@ -23,6 +25,7 @@
|
|||||||
"value" : "tinted"
|
"value" : "tinted"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"filename" : "cardboy-icon-tinted.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"platform" : "ios",
|
"platform" : "ios",
|
||||||
"size" : "1024x1024"
|
"size" : "1024x1024"
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
File diff suppressed because it is too large
Load Diff
|
After Width: | Height: | Size: 41 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 8.7 KiB |
File diff suppressed because it is too large
Load Diff
|
After Width: | Height: | Size: 41 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
File diff suppressed because it is too large
Load Diff
|
After Width: | Height: | Size: 41 KiB |
@@ -1,7 +1,48 @@
|
|||||||
|
import Combine
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
struct ContentView: View {
|
struct ContentView: View {
|
||||||
@EnvironmentObject private var manager: TimeSyncManager
|
@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 {
|
private var formattedLastSync: String {
|
||||||
guard let date = manager.lastSyncDate else { return "Never" }
|
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 {
|
#Preview {
|
||||||
ContentView()
|
ContentView()
|
||||||
.environmentObject(TimeSyncManager())
|
.environmentObject(TimeSyncManager())
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import Combine
|
import Combine
|
||||||
import CoreBluetooth
|
import CoreBluetooth
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
final class TimeSyncManager: NSObject, ObservableObject {
|
final class TimeSyncManager: NSObject, ObservableObject {
|
||||||
enum ConnectionState: String {
|
enum ConnectionState: String {
|
||||||
@@ -12,12 +13,82 @@ final class TimeSyncManager: NSObject, ObservableObject {
|
|||||||
case failed = "Failed"
|
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 connectionState: ConnectionState = .idle
|
||||||
@Published private(set) var statusMessage: String = "Waiting for Bluetooth…"
|
@Published private(set) var statusMessage: String = "Waiting for Bluetooth…"
|
||||||
@Published private(set) var lastSyncDate: Date?
|
@Published private(set) var lastSyncDate: Date?
|
||||||
|
|
||||||
private let serviceUUID = CBUUID(string: "00000001-CA7B-4EFD-B5A6-10C3F4D3F230")
|
@Published private(set) var currentDirectory: String = "/"
|
||||||
private let characteristicUUID = CBUUID(string: "00000002-CA7B-4EFD-B5A6-10C3F4D3F231")
|
@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(
|
private lazy var central: CBCentralManager = CBCentralManager(
|
||||||
delegate: self,
|
delegate: self,
|
||||||
@@ -30,25 +101,73 @@ final class TimeSyncManager: NSObject, ObservableObject {
|
|||||||
|
|
||||||
private var targetPeripheral: CBPeripheral?
|
private var targetPeripheral: CBPeripheral?
|
||||||
private var timeCharacteristic: CBCharacteristic?
|
private var timeCharacteristic: CBCharacteristic?
|
||||||
|
private var fileCommandCharacteristic: CBCharacteristic?
|
||||||
|
private var fileResponseCharacteristic: CBCharacteristic?
|
||||||
|
|
||||||
private var retryWorkItem: DispatchWorkItem?
|
private var retryWorkItem: DispatchWorkItem?
|
||||||
private var shouldKeepScanning = true
|
private var shouldKeepScanning = true
|
||||||
private var isScanning = false
|
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() {
|
override init() {
|
||||||
super.init()
|
super.init()
|
||||||
|
// Force central manager to initialise immediately so state updates arrive right away.
|
||||||
|
_ = central
|
||||||
|
|
||||||
|
if central.state == .poweredOn {
|
||||||
|
startScanning()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
retryWorkItem?.cancel()
|
retryWorkItem?.cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Public BLE Controls
|
||||||
|
|
||||||
func forceRescan() {
|
func forceRescan() {
|
||||||
statusMessage = "Restarting scan…"
|
statusMessage = "Restarting scan…"
|
||||||
shouldKeepScanning = true
|
shouldKeepScanning = true
|
||||||
|
retryWorkItem?.cancel()
|
||||||
|
|
||||||
|
let existingPeripheral = targetPeripheral
|
||||||
stopScanning()
|
stopScanning()
|
||||||
|
if let existingPeripheral {
|
||||||
|
central.cancelPeripheralConnection(existingPeripheral)
|
||||||
|
}
|
||||||
|
|
||||||
targetPeripheral = nil
|
targetPeripheral = nil
|
||||||
timeCharacteristic = nil
|
timeCharacteristic = nil
|
||||||
|
fileCommandCharacteristic = nil
|
||||||
|
fileResponseCharacteristic = nil
|
||||||
|
resetFileStateOnDisconnect()
|
||||||
startScanning()
|
startScanning()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,19 +194,155 @@ final class TimeSyncManager: NSObject, ObservableObject {
|
|||||||
payload.append(isDst)
|
payload.append(isDst)
|
||||||
payload.append(UInt8(0)) // Reserved byte
|
payload.append(UInt8(0)) // Reserved byte
|
||||||
|
|
||||||
connectionState = .ready
|
|
||||||
statusMessage = "Sending current time…"
|
|
||||||
peripheral.writeValue(payload, for: characteristic, type: .withResponse)
|
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() {
|
private func startScanning() {
|
||||||
guard shouldKeepScanning, central.state == .poweredOn else { return }
|
guard shouldKeepScanning, central.state == .poweredOn else { return }
|
||||||
if isScanning { return }
|
if isScanning { return }
|
||||||
|
central.scanForPeripherals(withServices: [timeServiceUUID, fileServiceUUID], options: [
|
||||||
central.scanForPeripherals(withServices: [serviceUUID], options: [
|
|
||||||
CBCentralManagerScanOptionAllowDuplicatesKey: false
|
CBCentralManagerScanOptionAllowDuplicatesKey: false
|
||||||
])
|
])
|
||||||
|
|
||||||
isScanning = true
|
isScanning = true
|
||||||
connectionState = .scanning
|
connectionState = .scanning
|
||||||
statusMessage = "Scanning for Cardboy…"
|
statusMessage = "Scanning for Cardboy…"
|
||||||
@@ -108,8 +363,372 @@ final class TimeSyncManager: NSObject, ObservableObject {
|
|||||||
retryWorkItem = workItem
|
retryWorkItem = workItem
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: 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 {
|
extension TimeSyncManager: CBCentralManagerDelegate {
|
||||||
func centralManager(_ central: CBCentralManager, willRestoreState dict: [String: Any]) {
|
func centralManager(_ central: CBCentralManager, willRestoreState dict: [String: Any]) {
|
||||||
shouldKeepScanning = true
|
shouldKeepScanning = true
|
||||||
@@ -151,8 +770,8 @@ extension TimeSyncManager: CBCentralManagerDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any],
|
func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral,
|
||||||
rssi RSSI: NSNumber) {
|
advertisementData: [String: Any], rssi RSSI: NSNumber) {
|
||||||
statusMessage = "Found \(peripheral.name ?? "device"), connecting…"
|
statusMessage = "Found \(peripheral.name ?? "device"), connecting…"
|
||||||
connectionState = .connecting
|
connectionState = .connecting
|
||||||
shouldKeepScanning = false
|
shouldKeepScanning = false
|
||||||
@@ -165,27 +784,31 @@ extension TimeSyncManager: CBCentralManagerDelegate {
|
|||||||
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
|
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
|
||||||
statusMessage = "Connected. Discovering services…"
|
statusMessage = "Connected. Discovering services…"
|
||||||
connectionState = .discovering
|
connectionState = .discovering
|
||||||
peripheral.discoverServices([serviceUUID])
|
peripheral.discoverServices([timeServiceUUID, fileServiceUUID])
|
||||||
}
|
}
|
||||||
|
|
||||||
func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) {
|
func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) {
|
||||||
statusMessage = "Failed to connect. \(error?.localizedDescription ?? "")"
|
statusMessage = "Failed to connect. \(error?.localizedDescription ?? "")"
|
||||||
connectionState = .failed
|
connectionState = .failed
|
||||||
targetPeripheral = nil
|
targetPeripheral = nil
|
||||||
timeCharacteristic = nil
|
resetFileStateOnDisconnect()
|
||||||
scheduleRetry()
|
scheduleRetry()
|
||||||
}
|
}
|
||||||
|
|
||||||
func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
|
func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
|
||||||
let reason = error?.localizedDescription ?? "Disconnected."
|
statusMessage = "Disconnected. \(error?.localizedDescription ?? "")"
|
||||||
statusMessage = reason
|
|
||||||
connectionState = .idle
|
connectionState = .idle
|
||||||
targetPeripheral = nil
|
targetPeripheral = nil
|
||||||
timeCharacteristic = nil
|
timeCharacteristic = nil
|
||||||
|
fileCommandCharacteristic = nil
|
||||||
|
fileResponseCharacteristic = nil
|
||||||
|
resetFileStateOnDisconnect()
|
||||||
scheduleRetry()
|
scheduleRetry()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - CBPeripheralDelegate
|
||||||
|
|
||||||
extension TimeSyncManager: CBPeripheralDelegate {
|
extension TimeSyncManager: CBPeripheralDelegate {
|
||||||
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
|
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
|
||||||
if let error {
|
if let error {
|
||||||
@@ -202,8 +825,12 @@ extension TimeSyncManager: CBPeripheralDelegate {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
for service in services where service.uuid == serviceUUID {
|
for service in services {
|
||||||
peripheral.discoverCharacteristics([characteristicUUID], for: service)
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let characteristics = service.characteristics,
|
guard let characteristics = service.characteristics else { return }
|
||||||
let targetCharacteristic = characteristics.first(where: { $0.uuid == characteristicUUID }) else {
|
|
||||||
statusMessage = "Time sync characteristic missing."
|
for characteristic in characteristics {
|
||||||
connectionState = .failed
|
if characteristic.uuid == timeCharacteristicUUID {
|
||||||
scheduleRetry()
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
timeCharacteristic = targetCharacteristic
|
if characteristic.uuid == fileResponseUUID, characteristic.isNotifying {
|
||||||
connectionState = .ready
|
if !flushPendingDirectoryRequest() {
|
||||||
statusMessage = "Ready to sync time."
|
refreshDirectory()
|
||||||
sendCurrentTime()
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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?) {
|
func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) {
|
||||||
if let error {
|
if characteristic.uuid == fileCommandUUID {
|
||||||
statusMessage = "Write failed: \(error.localizedDescription)"
|
if let error {
|
||||||
connectionState = .failed
|
fileErrorMessage = error.localizedDescription
|
||||||
scheduleRetry()
|
}
|
||||||
return
|
isWritingFileCommand = false
|
||||||
|
processFileCommandQueue(characteristic: characteristic)
|
||||||
}
|
}
|
||||||
|
|
||||||
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."
|
|
||||||
}
|
|
||||||
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
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
#include "cardboy/apps/clock_app.hpp"
|
#include "cardboy/apps/clock_app.hpp"
|
||||||
#include "cardboy/apps/gameboy_app.hpp"
|
#include "cardboy/apps/gameboy_app.hpp"
|
||||||
|
#include "cardboy/apps/lockscreen_app.hpp"
|
||||||
#include "cardboy/apps/menu_app.hpp"
|
#include "cardboy/apps/menu_app.hpp"
|
||||||
#include "cardboy/apps/settings_app.hpp"
|
#include "cardboy/apps/settings_app.hpp"
|
||||||
#include "cardboy/apps/snake_app.hpp"
|
#include "cardboy/apps/snake_app.hpp"
|
||||||
@@ -233,6 +234,7 @@ extern "C" void app_main() {
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
system.registerApp(apps::createMenuAppFactory());
|
system.registerApp(apps::createMenuAppFactory());
|
||||||
|
system.registerApp(apps::createLockscreenAppFactory());
|
||||||
system.registerApp(apps::createSettingsAppFactory());
|
system.registerApp(apps::createSettingsAppFactory());
|
||||||
system.registerApp(apps::createClockAppFactory());
|
system.registerApp(apps::createClockAppFactory());
|
||||||
system.registerApp(apps::createSnakeAppFactory());
|
system.registerApp(apps::createSnakeAppFactory());
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ target_link_libraries(cardboy_apps
|
|||||||
target_compile_features(cardboy_apps PUBLIC cxx_std_20)
|
target_compile_features(cardboy_apps PUBLIC cxx_std_20)
|
||||||
|
|
||||||
add_subdirectory(menu)
|
add_subdirectory(menu)
|
||||||
|
add_subdirectory(lockscreen)
|
||||||
add_subdirectory(clock)
|
add_subdirectory(clock)
|
||||||
add_subdirectory(settings)
|
add_subdirectory(settings)
|
||||||
add_subdirectory(gameboy)
|
add_subdirectory(gameboy)
|
||||||
|
|||||||
10
Firmware/sdk/apps/lockscreen/CMakeLists.txt
Normal file
10
Firmware/sdk/apps/lockscreen/CMakeLists.txt
Normal 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
|
||||||
|
)
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|
||||||
271
Firmware/sdk/apps/lockscreen/src/lockscreen_app.cpp
Normal file
271
Firmware/sdk/apps/lockscreen/src/lockscreen_app.cpp
Normal 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
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
#include "cardboy/apps/menu_app.hpp"
|
#include "cardboy/apps/menu_app.hpp"
|
||||||
|
#include "cardboy/apps/lockscreen_app.hpp"
|
||||||
|
|
||||||
#include "cardboy/sdk/app_framework.hpp"
|
#include "cardboy/sdk/app_framework.hpp"
|
||||||
#include "cardboy/sdk/app_system.hpp"
|
#include "cardboy/sdk/app_system.hpp"
|
||||||
@@ -6,6 +7,7 @@
|
|||||||
#include "cardboy/gfx/font16x8.hpp"
|
#include "cardboy/gfx/font16x8.hpp"
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
|
#include <cstdint>
|
||||||
#include <cstdlib>
|
#include <cstdlib>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <string_view>
|
#include <string_view>
|
||||||
@@ -19,6 +21,8 @@ using cardboy::sdk::AppContext;
|
|||||||
|
|
||||||
using Framebuffer = typename AppContext::Framebuffer;
|
using Framebuffer = typename AppContext::Framebuffer;
|
||||||
|
|
||||||
|
constexpr std::uint32_t kIdleTimeoutMs = 15000;
|
||||||
|
|
||||||
struct MenuEntry {
|
struct MenuEntry {
|
||||||
std::string name;
|
std::string name;
|
||||||
std::size_t index = 0;
|
std::size_t index = 0;
|
||||||
@@ -31,15 +35,46 @@ public:
|
|||||||
void onStart() override {
|
void onStart() override {
|
||||||
refreshEntries();
|
refreshEntries();
|
||||||
dirty = true;
|
dirty = true;
|
||||||
|
resetInactivityTimer();
|
||||||
renderIfNeeded();
|
renderIfNeeded();
|
||||||
}
|
}
|
||||||
|
|
||||||
void handleEvent(const cardboy::sdk::AppEvent& event) override {
|
void onStop() override { cancelInactivityTimer(); }
|
||||||
if (event.type != cardboy::sdk::AppEventType::Button)
|
|
||||||
return;
|
|
||||||
|
|
||||||
const auto& current = event.button.current;
|
void handleEvent(const cardboy::sdk::AppEvent& event) override {
|
||||||
const auto& previous = event.button.previous;
|
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) {
|
if (current.left && !previous.left) {
|
||||||
moveSelection(-1);
|
moveSelection(-1);
|
||||||
@@ -54,14 +89,6 @@ public:
|
|||||||
renderIfNeeded();
|
renderIfNeeded();
|
||||||
}
|
}
|
||||||
|
|
||||||
private:
|
|
||||||
AppContext& context;
|
|
||||||
Framebuffer& framebuffer;
|
|
||||||
std::vector<MenuEntry> entries;
|
|
||||||
std::size_t selected = 0;
|
|
||||||
|
|
||||||
bool dirty = false;
|
|
||||||
|
|
||||||
void moveSelection(int step) {
|
void moveSelection(int step) {
|
||||||
if (entries.empty())
|
if (entries.empty())
|
||||||
return;
|
return;
|
||||||
@@ -93,7 +120,8 @@ private:
|
|||||||
const char* name = factory->name();
|
const char* name = factory->name();
|
||||||
if (!name)
|
if (!name)
|
||||||
continue;
|
continue;
|
||||||
if (std::string_view(name) == kMenuAppNameView)
|
const std::string_view appName(name);
|
||||||
|
if (appName == kMenuAppNameView || appName == kLockscreenAppNameView)
|
||||||
continue;
|
continue;
|
||||||
entries.push_back(MenuEntry{std::string(name), i});
|
entries.push_back(MenuEntry{std::string(name), i});
|
||||||
}
|
}
|
||||||
@@ -159,6 +187,18 @@ private:
|
|||||||
|
|
||||||
framebuffer.sendFrame();
|
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 {
|
class MenuAppFactory final : public cardboy::sdk::IAppFactory {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
#include "cardboy/apps/clock_app.hpp"
|
#include "cardboy/apps/clock_app.hpp"
|
||||||
#include "cardboy/apps/gameboy_app.hpp"
|
#include "cardboy/apps/gameboy_app.hpp"
|
||||||
#include "cardboy/apps/menu_app.hpp"
|
#include "cardboy/apps/menu_app.hpp"
|
||||||
|
#include "cardboy/apps/lockscreen_app.hpp"
|
||||||
#include "cardboy/apps/settings_app.hpp"
|
#include "cardboy/apps/settings_app.hpp"
|
||||||
#include "cardboy/apps/snake_app.hpp"
|
#include "cardboy/apps/snake_app.hpp"
|
||||||
#include "cardboy/apps/tetris_app.hpp"
|
#include "cardboy/apps/tetris_app.hpp"
|
||||||
@@ -27,6 +28,7 @@ int main() {
|
|||||||
buzzer->setMuted(persistentSettings.mute);
|
buzzer->setMuted(persistentSettings.mute);
|
||||||
|
|
||||||
system.registerApp(apps::createMenuAppFactory());
|
system.registerApp(apps::createMenuAppFactory());
|
||||||
|
system.registerApp(apps::createLockscreenAppFactory());
|
||||||
system.registerApp(apps::createSettingsAppFactory());
|
system.registerApp(apps::createSettingsAppFactory());
|
||||||
system.registerApp(apps::createClockAppFactory());
|
system.registerApp(apps::createClockAppFactory());
|
||||||
system.registerApp(apps::createGameboyAppFactory());
|
system.registerApp(apps::createGameboyAppFactory());
|
||||||
|
|||||||
Reference in New Issue
Block a user