Skip to content

Commit 059555b

Browse files
committed
feat(preview): offer the USB preview transport on Linux
Gate it by live enumeration like the print dialog and fall back to network at fetch time when the selected USB device is gone.
1 parent 617afe2 commit 059555b

6 files changed

Lines changed: 92 additions & 61 deletions

File tree

‎src/components/Output/PrintToZebraDialog.tsx‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { isDesktopShell } from "../../lib/platform";
1414
import { getPrinterAddress, setPrinterAddress } from "../../lib/printerAddress";
1515
import { listLocalPrinters, sendZplLocal, isLikelyZebra, type LocalPrinter } from "../../lib/localPrint";
1616
import { sendZplUsb, setupUsbAccess, isLikelyZebra as isUsbZebra } from "../../lib/usbPrint";
17+
import { printerOptionLabel } from "../../lib/printerLabel";
1718
import { useUsbPrinters } from "../../hooks/useUsbPrinters";
1819

1920
const LS_PRINTER_UID = "zebra_print_uid";
@@ -332,7 +333,7 @@ export function PrintToZebraDialog({ zpl, onClose }: Props) {
332333
? [{ value: "", label: loadingLocal ? t.zebraPrint.discovering : t.zebraPrint.noPrinters }]
333334
: localPrinters.map((p) => ({
334335
value: p.system_name,
335-
label: isLikelyZebra(p) ? `${p.name} · ZPL?` : p.name,
336+
label: printerOptionLabel(p.name, isLikelyZebra(p)),
336337
})),
337338
selectDisabled: localPrinters.length === 0,
338339
onSend: handleLocalSend,
@@ -350,7 +351,7 @@ export function PrintToZebraDialog({ zpl, onClose }: Props) {
350351
? [{ value: "", label: usb.loading ? t.zebraPrint.discovering : t.zebraPrint.noPrinters }]
351352
: usb.printers.map((p) => ({
352353
value: p.id,
353-
label: isUsbZebra(p) ? `${p.name} · ZPL?` : p.name,
354+
label: printerOptionLabel(p.name, isUsbZebra(p)),
354355
})),
355356
selectDisabled: usb.printers.length === 0,
356357
onSend: handleUsbSend,

‎src/components/PrinterSettings/PreviewSettingsTab.tsx‎

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { useState, useEffect } from "react";
22
import { useT } from "../../hooks/useT";
33
import { useLabelStore, selectEffectivePreviewProvider } from "../../store/labelStore";
4-
import { isDesktopShell, isMacDesktop } from "../../lib/platform";
4+
import { isDesktopShell } from "../../lib/platform";
55
import { isDefaultHost } from "../../lib/labelary";
66
import {
77
getPreviewTransport,
@@ -11,6 +11,7 @@ import {
1111
type PreviewTransport,
1212
} from "../../lib/printerAddress";
1313
import { isLikelyZebra } from "../../lib/usbPrint";
14+
import { printerOptionLabel } from "../../lib/printerLabel";
1415
import { useUsbPrinters } from "../../hooks/useUsbPrinters";
1516
import { labelCls, inputCls, buttonCls } from "../ui/formStyles";
1617
import { Select } from "../ui/Select";
@@ -71,17 +72,19 @@ export function PreviewSettingsTab() {
7172
const a = getPrinterAddress();
7273
return { host: a.host, port: String(a.port) };
7374
});
74-
// USB preview needs the direct interface driver, which only macOS has; on
75-
// other desktops the transport stays network without offering the choice.
76-
const [transport, setTransport] = useState<PreviewTransport>(() =>
77-
isMacDesktop ? getPreviewTransport() : "network",
78-
);
75+
const [transport, setTransport] = useState<PreviewTransport>(getPreviewTransport);
7976
const selectTransport = (v: PreviewTransport) => {
8077
setTransport(v);
8178
setPreviewTransport(v);
8279
};
83-
// Shared with the print dialog so both pick from the same enumerated devices.
84-
const usb = useUsbPrinters(isMacDesktop);
80+
// Shared with the print dialog; enumerate only when the printer provider can
81+
// consume the result.
82+
const usb = useUsbPrinters(isDesktopShell && provider === "printer");
83+
// Unplugging every USB printer hides the transport section; render the
84+
// stranded 'usb' choice as network without persisting it, so a replug
85+
// restores the selection (mirrors the print dialog's stranded-tab fix).
86+
const usbAvailable = usb.printers.length > 0;
87+
const effectiveTransport: PreviewTransport = usbAvailable ? transport : "network";
8588
const persistAddress = () => {
8689
setPrinterAddress(address.host.trim(), address.port);
8790
// Snap the inputs to the validated values (host trimmed, an invalid/empty
@@ -149,27 +152,27 @@ export function PreviewSettingsTab() {
149152
/>
150153
</section>
151154

152-
{provider === "printer" && isMacDesktop && (
155+
{provider === "printer" && usbAvailable && (
153156
<section className="flex flex-col gap-2">
154157
<h3 className="font-mono text-[10px] uppercase tracking-widest text-muted">{loc.transportHeading}</h3>
155158
<RadioOption
156159
name="preview-transport"
157160
value="network"
158-
current={transport}
161+
current={effectiveTransport}
159162
onSelect={selectTransport}
160163
label={loc.transportNetwork}
161164
/>
162165
<RadioOption
163166
name="preview-transport"
164167
value="usb"
165-
current={transport}
168+
current={effectiveTransport}
166169
onSelect={selectTransport}
167170
label={loc.transportUsb}
168171
/>
169172
</section>
170173
)}
171174

172-
{provider === "printer" && isDesktopShell && transport === "network" && (
175+
{provider === "printer" && isDesktopShell && effectiveTransport === "network" && (
173176
<section className="flex flex-col gap-2">
174177
<h3 className="font-mono text-[10px] uppercase tracking-widest text-muted">{loc.printerAddressHeading}</h3>
175178
<div className="flex gap-2 max-w-md">
@@ -201,7 +204,7 @@ export function PreviewSettingsTab() {
201204
</section>
202205
)}
203206

204-
{provider === "printer" && transport === "usb" && (
207+
{provider === "printer" && effectiveTransport === "usb" && (
205208
<section className="flex flex-col gap-2">
206209
<h3 className="font-mono text-[10px] uppercase tracking-widest text-muted">{t.zebraPrint.printer}</h3>
207210
<div className="max-w-md">
@@ -215,7 +218,7 @@ export function PreviewSettingsTab() {
215218
? [{ value: "", label: usb.loading ? t.zebraPrint.discovering : t.zebraPrint.noPrinters }]
216219
: usb.printers.map((p) => ({
217220
value: p.id,
218-
label: isLikelyZebra(p) ? `${p.name} · ZPL?` : p.name,
221+
label: printerOptionLabel(p.name, isLikelyZebra(p)),
219222
})),
220223
}]}
221224
/>

‎src/lib/printerLabel.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
/** Picker label: the name, suffixed "· ZPL?" when it looks ZPL-capable so the
2+
* user can spot the Zebra among unrelated print queues. */
3+
export function printerOptionLabel(name: string, isZebra: boolean): string {
4+
return isZebra ? `${name} · ZPL?` : name;
5+
}

‎src/lib/printerPreview.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ type PrinterQueryFailure =
2020
| { kind: "refused" }
2121
| { kind: "unreachable" }
2222
| { kind: "not_found" }
23+
| { kind: "permission_denied" }
2324
| { kind: "error"; message: string };
2425

2526
export type PrinterPreviewResult = { kind: "bitmap"; bitmap: PrinterBitmap } | PrinterQueryFailure;

‎src/lib/usbPrint.ts‎

Lines changed: 28 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -8,51 +8,59 @@ export interface UsbPrinter {
88
vendor_id: string;
99
}
1010

11+
/** The invoke-rejection member shared by the USB result unions. */
12+
interface UsbError {
13+
kind: "error";
14+
message: string;
15+
}
16+
1117
export type UsbPrintResult =
1218
| { kind: "sent" }
1319
| { kind: "permission_denied" }
1420
| { kind: "not_found" }
15-
| { kind: "error"; message: string };
21+
| UsbError;
22+
23+
/** Mirrors the Rust UsbQueryResult; `error` covers the invoke rejection. */
24+
export type UsbQueryResult =
25+
| { kind: "data"; body: string }
26+
| { kind: "permission_denied" }
27+
| { kind: "not_found" }
28+
| UsbError;
1629

1730
/** Hint only for sorting/labels: the Zebra USB vendor id. */
1831
export function isLikelyZebra(p: UsbPrinter): boolean {
1932
return p.vendor_id.toLowerCase() === "0a5f";
2033
}
2134

22-
/** Desktop shell only; the web build and unsupported desktops (Windows) return
23-
* nothing. Linux enumerates via usblp, macOS via IOKit. */
35+
/** Desktop shell only (web and Windows return nothing). */
2436
export async function listUsbPrinters(): Promise<UsbPrinter[]> {
2537
if (!isDesktopShell) return [];
2638
const { invoke } = await import("@tauri-apps/api/core");
2739
return await invoke<UsbPrinter[]>("list_usb_printers");
2840
}
2941

30-
export async function sendZplUsb(id: string, zpl: string): Promise<UsbPrintResult> {
42+
/** Desktop guard + lazy Tauri import; a rejection folds into the union's
43+
* `error` member. */
44+
async function invokeUsbCommand<R>(
45+
cmd: string,
46+
args: Record<string, unknown>,
47+
): Promise<R | UsbError> {
3148
if (!isDesktopShell) return { kind: "error", message: "USB printing requires the desktop app" };
3249
const { invoke } = await import("@tauri-apps/api/core");
3350
try {
34-
return await invoke<UsbPrintResult>("send_zpl_usb", { device: id, zpl });
51+
return await invoke<R>(cmd, args);
3552
} catch (e) {
3653
return { kind: "error", message: errorMessage(e) };
3754
}
3855
}
3956

40-
/** Mirrors the Rust UsbQueryResult; `error` covers the invoke rejection. */
41-
export type UsbQueryResult =
42-
| { kind: "data"; body: string }
43-
| { kind: "not_found" }
44-
| { kind: "error"; message: string };
57+
export function sendZplUsb(id: string, zpl: string): Promise<UsbPrintResult> {
58+
return invokeUsbCommand<UsbPrintResult>("send_zpl_usb", { device: id, zpl });
59+
}
4560

46-
/** Send ZPL and read the printer's reply over the bulk-in endpoint (macOS
47-
* only; the Linux usblp transport has no read side wired up). */
48-
export async function queryZplUsb(id: string, zpl: string): Promise<UsbQueryResult> {
49-
if (!isDesktopShell) return { kind: "error", message: "USB printing requires the desktop app" };
50-
const { invoke } = await import("@tauri-apps/api/core");
51-
try {
52-
return await invoke<UsbQueryResult>("query_zpl_usb", { device: id, zpl });
53-
} catch (e) {
54-
return { kind: "error", message: errorMessage(e) };
55-
}
61+
/** Send ZPL and read the printer's reply. Desktop transports only. */
62+
export function queryZplUsb(id: string, zpl: string): Promise<UsbQueryResult> {
63+
return invokeUsbCommand<UsbQueryResult>("query_zpl_usb", { device: id, zpl });
5664
}
5765

5866
export async function setupUsbAccess(): Promise<void> {

‎src/store/slices/previewSlice.ts‎

Lines changed: 38 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import {
88
type PrinterRenderDims,
99
} from '../../lib/printerPreview';
1010
import { getPreviewTransport, getPrinterAddress, getUsbPrinterId } from '../../lib/printerAddress';
11-
import { isMacDesktop } from '../../lib/platform';
1211
import { buildActiveCsvRow } from '../../lib/variableBinding';
1312
import { buildPreviewZpl } from '../../lib/printPreview';
1413
import { currentObjects, selectEffectivePreviewProvider, selectLabelaryEndpoint } from '../labelStore.selectors';
@@ -52,22 +51,19 @@ const previewCache = (() => {
5251
export const __resetPreviewCacheForTests = (): void => previewCache._resetForTests();
5352

5453
/** The configured preview target, or the message telling the user what to
55-
* configure. USB and network live in separate settings so an incomplete one
56-
* never silently falls back to the other. */
54+
* configure. */
5755
function resolvePreviewTarget(): { target: PreviewTarget } | { error: string } {
58-
// USB query is macOS-only, and the settings UI only offers it there; gate the
59-
// same way so a persisted 'usb' choice carried onto another OS falls back to
60-
// the network address instead of routing to a command that always errors.
61-
if (isMacDesktop && getPreviewTransport() === 'usb') {
56+
// Keep a persisted 'usb' choice even if the device is gone: enterPreviewMode
57+
// falls back to network at fetch time, so a re-plug restores it. An empty
58+
// selection falls through to network.
59+
if (getPreviewTransport() === 'usb') {
6260
const id = getUsbPrinterId();
63-
return id
64-
? { target: { kind: 'usb', id } }
65-
: { error: 'No USB printer selected. Pick one under Settings, Preview.' };
61+
if (id) return { target: { kind: 'usb', id } };
6662
}
6763
const { host, port } = getPrinterAddress();
6864
return host
6965
? { target: { kind: 'network', host, port } }
70-
: { error: 'No printer address configured. Set the IP under Settings, Preview.' };
66+
: { error: 'No printer configured. Set a USB device or IP under Settings, Preview.' };
7167
}
7268

7369
/** Cache-key part identifying the device, so switching the preview transport
@@ -78,8 +74,7 @@ function previewTargetKey(target: PreviewTarget): string {
7874

7975
export interface PreviewSlice {
8076
previewMode: PreviewMode;
81-
/** Render current page → fetch → set status. Caller-checked: only
82-
* call when `previewMode.status` is `idle` or `error`. */
77+
/** Caller-checked: only call when `previewMode.status` is `idle` or `error`. */
8378
enterPreviewMode: () => Promise<void>;
8479
/** Reset to `idle`; blob URL stays cached for re-toggle. */
8580
exitPreviewMode: () => void;
@@ -122,14 +117,16 @@ export const createPreviewSlice: StateCreator<LabelState, [], [], PreviewSlice>
122117
// render; the printer path folds the target in instead. NUL-joined so a ':'
123118
// inside any field can't shift a boundary and collide.
124119
const endpoint = selectLabelaryEndpoint(state);
120+
const printerKey = (t: PreviewTarget): string => [provider, previewTargetKey(t), zpl].join('\0');
121+
const serveCached = (k: string): boolean => {
122+
const hit = previewCache.get(k);
123+
if (hit) set({ previewMode: { status: 'active', ...hit } });
124+
return hit !== null;
125+
};
125126
const cacheKey = printerTarget
126-
? [provider, previewTargetKey(printerTarget), zpl].join('\0')
127+
? printerKey(printerTarget)
127128
: [provider, endpoint.host, endpoint.apiKey ?? '', zpl].join('\0');
128-
const cached = previewCache.get(cacheKey);
129-
if (cached !== null) {
130-
set({ previewMode: { status: 'active', ...cached } });
131-
return;
132-
}
129+
if (serveCached(cacheKey)) return;
133130
set({ previewMode: { status: 'loading' } });
134131
// Stale-request guard: status check catches an exit mid-fetch; the
135132
// reference-equality check catches re-entry with a different design
@@ -147,8 +144,22 @@ export const createPreviewSlice: StateCreator<LabelState, [], [], PreviewSlice>
147144
const fail = (error: string): void => {
148145
if (!isStale()) set({ previewMode: { status: 'error', error } });
149146
};
150-
const result = await fetchPrinterPreview(printerTarget, zpl);
147+
let target = printerTarget;
148+
let key = cacheKey;
149+
let result = await fetchPrinterPreview(target, zpl);
151150
if (isStale()) return;
151+
// USB device gone: preview over network without touching the persisted
152+
// USB choice (resolvePreviewTarget explains why).
153+
if (target.kind === 'usb' && result.kind === 'not_found') {
154+
const net = getPrinterAddress();
155+
if (net.host) {
156+
target = { kind: 'network', host: net.host, port: net.port };
157+
key = printerKey(target);
158+
if (serveCached(key)) return;
159+
result = await fetchPrinterPreview(target, zpl);
160+
if (isStale()) return;
161+
}
162+
}
152163
switch (result.kind) {
153164
case 'bitmap': {
154165
const url = bitmapToDataUrl(result.bitmap);
@@ -160,16 +171,15 @@ export const createPreviewSlice: StateCreator<LabelState, [], [], PreviewSlice>
160171
url,
161172
printerDims: printerRenderDims(result.bitmap),
162173
};
163-
previewCache.set(cacheKey, render);
174+
previewCache.set(key, render);
164175
set({ previewMode: { status: 'active', ...render } });
165176
return;
166177
}
167178
case 'refused': {
168-
// 'refused' only arises from the network transport (USB has no such
169-
// kind), so the port hint is appended whenever a network target is
170-
// present rather than duplicated into an unreachable USB message.
179+
// 'refused' is network-only (USB has no such kind), so gate the port
180+
// hint on a network target.
171181
const hint =
172-
printerTarget.kind === 'network' ? ` Check that port ${printerTarget.port} is open.` : '';
182+
target.kind === 'network' ? ` Check that port ${target.port} is open.` : '';
173183
fail(`The printer refused the connection.${hint}`);
174184
return;
175185
}
@@ -179,6 +189,9 @@ export const createPreviewSlice: StateCreator<LabelState, [], [], PreviewSlice>
179189
case 'not_found':
180190
fail('USB printer not found. Re-plug it and check Settings, Preview.');
181191
return;
192+
case 'permission_denied':
193+
fail('No access to the USB printer. Grant it in the print dialog, USB tab.');
194+
return;
182195
case 'error':
183196
fail(result.message);
184197
return;

0 commit comments

Comments
 (0)