Skip to content

Commit 4b22c6b

Browse files
committed
feat: prepare PhotoDock for marketplace review
1 parent 52415aa commit 4b22c6b

29 files changed

Lines changed: 1132 additions & 185 deletions

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,14 @@ All notable changes follow Keep a Changelog-style sections.
66

77
### Changed
88

9+
- Prepared truthful marketplace copy and a privacy-safe preview workflow that
10+
does not claim the still-guarded mount, unmount, or eject actions.
11+
- Kept the dynamic Clear selection action in the explicit keyboard focus chain
12+
and generalized the safety banner to cover folders and camera media.
13+
- Serialized job startup, bound backups to the recorded primary hash, and made
14+
every failed transaction clean or retain recoverable owned-temp evidence.
15+
- Bounded whole-source traversal and preview-helper output, rejected unsafe
16+
filename encodings, honored engine idle timeout, and isolated QML smoke state.
917
- Repositioned PhotoDock as an Omarchy photo and image viewer first, with safe
1018
camera-media import as an optional companion workflow.
1119
- Renamed the pre-release product from Shutter Bar to PhotoDock, including the

Panel.qml

Lines changed: 51 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,9 @@ Item {
7676
readonly property real mediaListViewportHeight: mediaList.height
7777
readonly property real mediaDetailViewportWidth: mediaDetailFocus.width
7878
readonly property real mediaDetailViewportHeight: mediaDetailFocus.height
79+
readonly property bool mediaGridFocusRequested: mediaGrid.focus
80+
readonly property bool mediaListFocusRequested: mediaList.focus
81+
readonly property bool clearSelectionFocusRequested: clearSelectionButton.focus
7982
readonly property real responsiveLayoutWidth: testLayoutWidth > 0 ? testLayoutWidth : window.width
8083
readonly property real responsiveLayoutHeight: testLayoutHeight > 0 ? testLayoutHeight : window.height
8184
readonly property bool compactLayout: responsiveLayoutWidth < 900
@@ -96,6 +99,9 @@ Item {
9699
readonly property real primaryImportActionHeight: primaryImportActionBar.height
97100
readonly property bool primaryImportActionVisible: primaryImportActionBar.visible
98101
readonly property bool emptySourceStateVisible: photoDockEmptyStateCard.visible
102+
readonly property bool clearSelectionInKeyboardChain:
103+
individualFilesCheck.KeyNavigation.tab === clearSelectionButton
104+
&& clearSelectionButton.KeyNavigation.backtab === individualFilesCheck
99105

100106
function mediaGridDelegateAt(index) {
101107
return mediaGrid.itemAtIndex(index)
@@ -494,6 +500,19 @@ Item {
494500
})
495501
}
496502

503+
function showMediaBrowseMode(mode) {
504+
if (mode !== "grid" && mode !== "list") return
505+
mediaBrowseMode = mode
506+
Qt.callLater(function() {
507+
if (mode === "grid") mediaGrid.forceActiveFocus(Qt.TabFocusReason)
508+
else mediaList.forceActiveFocus(Qt.TabFocusReason)
509+
})
510+
}
511+
512+
function focusClearSelectionForTesting() {
513+
if (clearSelectionButton.visible) clearSelectionButton.forceActiveFocus(Qt.TabFocusReason)
514+
}
515+
497516
function metadataRows() {
498517
if (!inspectedMedia || !inspectedMedia.metadata) return []
499518
var values = inspectedMedia.metadata
@@ -703,7 +722,7 @@ Item {
703722
anchors.fill: parent
704723
anchors.margins: 10
705724
text: root.effectiveService && root.effectiveService.snapshot ? root.effectiveService.snapshot.safetyStatement
706-
: "Your source stays untouched. PhotoDock never deletes, renames, edits, or formats files on camera media."
725+
: "Your source stays untouched. PhotoDock never deletes, renames, edits, or formats source files."
707726
color: Color.foreground
708727
wrapMode: Text.WordWrap
709728
}
@@ -717,6 +736,10 @@ Item {
717736
color: Qt.rgba(Color.urgent.r, Color.urgent.g, Color.urgent.b, 0.10)
718737
border.width: 1
719738
border.color: Qt.rgba(Color.urgent.r, Color.urgent.g, Color.urgent.b, 0.25)
739+
Accessible.role: Accessible.AlertMessage
740+
Accessible.name: root.errorText || (root.effectiveService && root.effectiveService.lastError
741+
? (root.effectiveService.lastError.message || root.effectiveService.lastError.code)
742+
: "")
720743
RowLayout {
721744
id: errorRow
722745
anchors.fill: parent
@@ -748,6 +771,8 @@ Item {
748771
color: Color.muted
749772
font.pixelSize: 11
750773
wrapMode: Text.WordWrap
774+
Accessible.role: Accessible.AlertMessage
775+
Accessible.name: root.notice
751776
}
752777

753778
Controls.ScrollView {
@@ -788,7 +813,7 @@ Item {
788813
}
789814
Text {
790815
Layout.fillWidth: true
791-
text: "Choose a photo folder, or insert an SD card, CFexpress card, supported USB mass-storage camera, or card reader. Browse photos before deciding what to import."
816+
text: "Choose a mounted photo folder or removable camera source that Omarchy has already mounted. PhotoDock does not mount or eject media in this alpha."
792817
color: Color.muted
793818
wrapMode: Text.WordWrap
794819
}
@@ -937,7 +962,7 @@ Item {
937962
checked: root.mediaBrowseMode === "grid"
938963
KeyNavigation.tab: listModeButton
939964
KeyNavigation.backtab: forgetFolderButton.visible ? forgetFolderButton : addFolderButton
940-
onClicked: root.mediaBrowseMode = "grid"
965+
onClicked: root.showMediaBrowseMode("grid")
941966
Accessible.name: "Show media as a thumbnail grid"
942967
}
943968
Controls.Button {
@@ -947,15 +972,16 @@ Item {
947972
checked: root.mediaBrowseMode === "list"
948973
KeyNavigation.tab: individualFilesCheck
949974
KeyNavigation.backtab: gridModeButton
950-
onClicked: root.mediaBrowseMode = "list"
975+
onClicked: root.showMediaBrowseMode("list")
951976
Accessible.name: "Show media as a compact list"
952977
}
953978
Controls.CheckBox {
954979
id: individualFilesCheck
955980
text: "Choose individual files"
956981
checked: root.limitToSelectedFiles
957982
enabled: root.inventoryTotal > 0 && !root.activeJob
958-
KeyNavigation.tab: previousPageButton.enabled ? previousPageButton
983+
KeyNavigation.tab: clearSelectionButton.visible ? clearSelectionButton
984+
: previousPageButton.enabled ? previousPageButton
959985
: nextPageButton.enabled ? nextPageButton : continueImportButton
960986
KeyNavigation.backtab: listModeButton
961987
onToggled: {
@@ -976,9 +1002,13 @@ Item {
9761002
font.pixelSize: 11
9771003
}
9781004
Controls.Button {
1005+
id: clearSelectionButton
9791006
text: "Clear"
9801007
visible: root.selectedFileIds.length > 0
9811008
enabled: !root.activeJob
1009+
KeyNavigation.tab: previousPageButton.enabled ? previousPageButton
1010+
: nextPageButton.enabled ? nextPageButton : continueImportButton
1011+
KeyNavigation.backtab: individualFilesCheck
9821012
onClicked: root.clearFileSelection()
9831013
Accessible.name: "Clear selected files"
9841014
}
@@ -992,7 +1022,8 @@ Item {
9921022
text: "‹ Previous"
9931023
enabled: root.inventoryOffset > 0 && !root.inventoryBusy
9941024
KeyNavigation.tab: nextPageButton.enabled ? nextPageButton : continueImportButton
995-
KeyNavigation.backtab: individualFilesCheck
1025+
KeyNavigation.backtab: clearSelectionButton.visible
1026+
? clearSelectionButton : individualFilesCheck
9961027
onClicked: root.loadInventory(Math.max(0, root.inventoryOffset - root.inventoryPageSize))
9971028
Accessible.name: "Previous media page"
9981029
}
@@ -1001,7 +1032,8 @@ Item {
10011032
text: "Next ›"
10021033
enabled: root.inventoryHasMore && !root.inventoryBusy
10031034
KeyNavigation.tab: continueImportButton
1004-
KeyNavigation.backtab: previousPageButton.enabled ? previousPageButton : individualFilesCheck
1035+
KeyNavigation.backtab: previousPageButton.enabled ? previousPageButton
1036+
: clearSelectionButton.visible ? clearSelectionButton : individualFilesCheck
10051037
onClicked: root.loadInventory(root.inventoryOffset + root.inventoryPageSize)
10061038
Accessible.name: "Next media page"
10071039
}
@@ -1012,7 +1044,8 @@ Item {
10121044
enabled: root.inventoryTotal > 0
10131045
KeyNavigation.tab: root.mediaBrowseMode === "grid" ? mediaGrid : mediaList
10141046
KeyNavigation.backtab: nextPageButton.enabled ? nextPageButton
1015-
: previousPageButton.enabled ? previousPageButton : individualFilesCheck
1047+
: previousPageButton.enabled ? previousPageButton
1048+
: clearSelectionButton.visible ? clearSelectionButton : individualFilesCheck
10161049
onClicked: {
10171050
root.revealWorkflowItem(importDetailsHeader)
10181051
shootNameField.forceActiveFocus()
@@ -1104,7 +1137,7 @@ Item {
11041137
keyNavigationWraps: false
11051138
activeFocusOnTab: true
11061139
Accessible.role: Accessible.List
1107-
Accessible.name: "Media thumbnail grid; arrow keys move, Shift plus arrow selects a range, Enter opens, Space toggles selection, Control-click toggles, and Shift-click selects a range"
1140+
Accessible.name: "Media thumbnail grid; arrow keys move, Shift plus arrow selects a range, Enter opens, Space toggles selection, Control or Command click toggles, Shift click selects a range, and Control or Command plus Shift click adds a range"
11081141
KeyNavigation.tab: shootNameField
11091142
KeyNavigation.backtab: continueImportButton
11101143
Keys.priority: Keys.BeforeItem
@@ -1162,7 +1195,7 @@ Item {
11621195
highlighted: root.isFileSelected(mediaTile.modelData.fileId)
11631196
onClicked: root.activateMediaItem(mediaTile.index, Qt.NoModifier)
11641197
Accessible.name: "Open " + mediaTile.modelData.fileName
1165-
Accessible.description: "Control-click toggles this file; Shift-click selects a range"
1198+
Accessible.description: "Control or Command click toggles this file; Shift click selects a range; Control or Command plus Shift click adds a range"
11661199
contentItem: ColumnLayout {
11671200
spacing: 4
11681201
Rectangle {
@@ -1282,6 +1315,8 @@ Item {
12821315
spacing: 2
12831316
model: root.inventoryItems
12841317
activeFocusOnTab: true
1318+
Accessible.role: Accessible.List
1319+
Accessible.name: "Media list; Up and Down arrows move, Shift plus arrow selects a range, Enter opens, Space toggles selection, Control or Command click toggles, Shift click selects a range, and Control or Command plus Shift click adds a range"
12851320
KeyNavigation.tab: shootNameField
12861321
KeyNavigation.backtab: continueImportButton
12871322
Keys.priority: Keys.BeforeItem
@@ -1370,7 +1405,7 @@ Item {
13701405
event.accepted = true
13711406
}
13721407
Accessible.name: "Open " + mediaRow.modelData.fileName + "; press Space to select it"
1373-
Accessible.description: "Control-click toggles this file; Shift-click selects a range"
1408+
Accessible.description: "Control or Command click toggles this file; Shift click selects a range; Control or Command plus Shift click adds a range"
13741409
contentItem: Column {
13751410
spacing: 2
13761411
Text { width: parent.width; text: mediaRow.modelData.fileName; color: Color.foreground; elide: Text.ElideMiddle }
@@ -1633,7 +1668,7 @@ Item {
16331668
visible: root.lastOutcome !== null && root.activeJob === null
16341669
Text { text: "LAST OUTCOME"; color: Color.muted; font.pixelSize: 11; font.weight: Font.DemiBold }
16351670
Text { Layout.fillWidth: true; text: root.lastOutcome ? root.lastOutcome.summary : ""; color: Color.foreground; wrapMode: Text.WordWrap }
1636-
Text { Layout.fillWidth: true; text: root.lastOutcome ? "Primary: " + root.lastOutcome.primary + " · Verification: " + root.lastOutcome.verification + " · Backup: " + root.lastOutcome.backup + " · Eject: " + root.lastOutcome.eject : ""; color: Color.muted; wrapMode: Text.WordWrap }
1671+
Text { Layout.fillWidth: true; text: root.lastOutcome ? "Primary: " + root.lastOutcome.primary + " · Verification: " + root.lastOutcome.verification + " · Backup: " + root.lastOutcome.backup : ""; color: Color.muted; wrapMode: Text.WordWrap }
16371672
}
16381673
}
16391674
}
@@ -1684,8 +1719,10 @@ Item {
16841719
highlighted: true
16851720
enabled: root.effectiveService && !root.activeJob && !root.planBusy
16861721
onClicked: root.primaryImportAction()
1687-
Accessible.name: root.reviewedPlanReady
1688-
? "Start the reviewed import plan" : "Review settings and prepare to start import"
1722+
Accessible.name: root.activeJob ? "Import in progress"
1723+
: root.planBusy ? "Checking import settings"
1724+
: root.reviewedPlanReady ? "Start the reviewed import plan"
1725+
: "Review settings and prepare to start import"
16891726
Accessible.description: root.primaryImportActionHint
16901727
}
16911728
}

README.md

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,19 @@
22

33
**View photos and safely import camera media on Omarchy.**
44

5-
> **Connect → View → Select → Import → Verify → Back Up → Eject**
5+
> **Connect → View → Select → Import → Verify → Back Up**
6+
7+
![PhotoDock viewing a synthetic demo photo on Omarchy](preview.png)
8+
9+
_Marketplace preview captured from PhotoDock with a synthetic demo image. It
10+
contains no personal photo, camera serial number, GPS data, or source metadata._
611

712
PhotoDock is a local-first photo and image viewer for Omarchy. Open a mounted
813
folder or camera card to browse thumbnails, switch between Grid and List views,
914
and open a large photo view—even when you only want to look at photos and do not
10-
want to import them. When you are ready, PhotoDock can safely move selected
15+
want to import them. When you are ready, PhotoDock can safely copy selected
1116
photos and videos into durable local storage, verify the copies, create an
12-
optional independent backup, and eject the source when you ask.
17+
optional independent backup, and leave the source files untouched.
1318

1419
**PhotoDock never deletes, renames, edits, or formats files on source media.**
1520

@@ -93,7 +98,8 @@ make validate
9398
proven.
9499
- Completed primary files survive cancel, backup failure, UI reload, and
95100
recovery.
96-
- Unmount/eject is explicit and never forced.
101+
- The planned unmount/eject flow is explicit and never forced; it remains
102+
guarded in this candidate until its disposable-hardware matrix is complete.
97103

98104
See [docs/safety.md](docs/safety.md) for the complete invariant contract.
99105

Service.qml

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ Item {
1717
property bool subscriptionRequested: false
1818
property var snapshot: ({
1919
sources: [], activeJob: null, lastOutcome: null,
20-
safetyStatement: "Your source stays untouched. PhotoDock never deletes, renames, edits, or formats files on camera media."
20+
safetyStatement: "Your source stays untouched. PhotoDock never deletes, renames, edits, or formats source files."
2121
})
2222
property var capabilities: ({})
2323
property var lastError: null
@@ -48,6 +48,16 @@ Item {
4848
}
4949

5050
function request(method, params, callback) {
51+
var connectionRequired = method === "start_job" || method === "request_cancel_job"
52+
if (connectionRequired && (!ready || !commandHandshaken || !commandSocket.connected)) {
53+
var connectionError = {
54+
code: "PD_ENGINE_DISCONNECTED",
55+
message: "Reconnect and refresh PhotoDock status before retrying this action."
56+
}
57+
lastError = connectionError
58+
if (callback) callback(false, connectionError)
59+
return
60+
}
5161
var queue = requestQueue.slice()
5262
queue.push({ method: String(method), params: params || ({}), callback: callback || null })
5363
requestQueue = queue
@@ -102,6 +112,26 @@ Item {
102112
commandSocket.flush()
103113
}
104114

115+
function failPendingRequest(message) {
116+
if (!pendingRequest) return
117+
var completed = pendingRequest
118+
pendingRequest = null
119+
var error = {
120+
code: "PD_ENGINE_DISCONNECTED",
121+
message: message || "The import engine connection closed before the request completed."
122+
}
123+
lastError = error
124+
if (completed.callback) completed.callback(false, error)
125+
}
126+
127+
function handleCommandDisconnect() {
128+
commandHandshaken = false
129+
ready = false
130+
engineAvailable = false
131+
failPendingRequest("The engine connection closed before PhotoDock could confirm the request. Reconnect and refresh status before retrying.")
132+
reconnectTimer.restart()
133+
}
134+
105135
function handleCommandFrame(raw) {
106136
var frame
107137
try { frame = JSON.parse(String(raw)) }
@@ -159,6 +189,9 @@ Item {
159189
commandHandshaken = false
160190
subscriptionHandshaken = false
161191
subscriptionRequested = false
192+
ready = false
193+
engineAvailable = false
194+
failPendingRequest("The engine connection reset before PhotoDock could confirm the request. Reconnect and refresh status before retrying.")
162195
commandSocket.connected = false
163196
subscriptionSocket.connected = false
164197
if (!ensureProcess.running) ensureProcess.running = true
@@ -192,9 +225,9 @@ Item {
192225
parser: SplitParser { onRead: function(data) { root.handleCommandFrame(data) } }
193226
onConnectionStateChanged: {
194227
if (connected) root.sendHello(commandSocket, "qml-command-hello")
195-
else root.commandHandshaken = false
228+
else root.handleCommandDisconnect()
196229
}
197-
onError: reconnectTimer.restart()
230+
onError: root.handleCommandDisconnect()
198231
}
199232

200233
Socket {

0 commit comments

Comments
 (0)