diff --git a/README.md b/README.md
index f0198e9..0c797a3 100644
--- a/README.md
+++ b/README.md
@@ -98,7 +98,18 @@ Build, replace the copy in `/Applications`, and relaunch — one command:
npm run ship
```
-No disk image; nothing here is being distributed.
+One build, two destinations, every time:
+
+- `/Applications/Work.app` — the copy tested here
+- `~/Desktop/Work-.zip` — the copy to send someone else
+
+Both come from the same build, so a tester runs byte for byte what was just verified
+rather than a second build that drifted. The zip is made with `ditto`, not `zip`: a plain
+zip mangles the bundle's symlinks and the app will not open on the other machine.
+
+The app is ad-hoc signed, so the first launch on someone else's Mac needs
+**right-click → Open** rather than a double-click. Gatekeeper refuses it silently
+otherwise.
## Configuration
diff --git a/package.json b/package.json
index 288db41..14462ec 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "work-app",
"private": true,
- "version": "0.1.0",
+ "version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/scripts/ship.sh b/scripts/ship.sh
index 8a43f15..a1cb8b9 100755
--- a/scripts/ship.sh
+++ b/scripts/ship.sh
@@ -1,25 +1,30 @@
#!/usr/bin/env bash
#
-# Build, install to /Applications, relaunch.
+# Build once, then put the result in both places, every time:
#
-# The standing workflow for seeing a change: this replaces the installed app
-# rather than producing a disk image, because nothing here is being distributed.
+# /Applications/Work.app the copy being tested here
+# ~/Desktop/Work-.zip the copy to send someone else
+#
+# One build, two destinations — so the thing a tester runs is byte for byte the
+# thing that was just tested here, rather than a second build that drifted.
set -euo pipefail
cd "$(dirname "$0")/.."
APP="Work.app"
BUILT="src-tauri/target/release/bundle/macos/$APP"
DEST="/Applications/$APP"
+VERSION="$(node -p "require('./package.json').version")"
+ZIP="$HOME/Desktop/Work-$VERSION.zip"
-echo "==> building"
+echo "==> building $VERSION"
npm run tauri build -- --bundles app
echo "==> quitting the running copy"
osascript -e 'quit app "Work"' >/dev/null 2>&1 || true
# A hung window would otherwise keep the bundle busy while it is replaced.
-pkill -x Work >/dev/null 2>&1 || true
+pkill -f "Work.app/Contents/MacOS" >/dev/null 2>&1 || true
for _ in 1 2 3 4 5 6 7 8 9 10; do
- pgrep -x Work >/dev/null || break
+ pgrep -f "Work.app/Contents/MacOS" >/dev/null || break
sleep 0.3
done
@@ -27,6 +32,12 @@ echo "==> installing to $DEST"
rm -rf "$DEST"
cp -R "$BUILT" "$DEST"
+# ditto rather than zip: it keeps the bundle's symlinks and resource forks,
+# which a plain zip mangles into an app that will not open on the other end.
+echo "==> packaging to $ZIP"
+rm -f "$ZIP"
+ditto -c -k --sequesterRsrc --keepParent "$BUILT" "$ZIP"
+
echo "==> launching"
open "$DEST"
-echo "==> done"
+echo "==> done — $DEST and $ZIP"
diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock
index dc9c4f0..4e4aeba 100644
--- a/src-tauri/Cargo.lock
+++ b/src-tauri/Cargo.lock
@@ -4911,7 +4911,7 @@ checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]]
name = "work-app"
-version = "0.1.0"
+version = "0.0.1"
dependencies = [
"block2",
"mac-notification-sys",
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
index 61ef287..3d3e1bb 100644
--- a/src-tauri/Cargo.toml
+++ b/src-tauri/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "work-app"
-version = "0.1.0"
+version = "0.0.1"
description = "A browser for the tools you work in"
authors = ["Vincent"]
edition = "2021"
diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs
index ca3ce51..393d4ba 100644
--- a/src-tauri/src/commands.rs
+++ b/src-tauri/src/commands.rs
@@ -767,6 +767,26 @@ pub fn app_reports(state: State<'_, AppState>) -> Vec<(String, String)> {
.collect()
}
+/// Puts the app list, groups, hidden elements and zoom back to defaults.
+///
+/// Settings only. Sessions are left alone — they live in WebKit's own store,
+/// not in `apps.json`, and throwing them away would mean signing back into
+/// every tool to undo a change to the nav.
+#[tauri::command]
+pub fn reset_config(app: AppHandle, state: State<'_, AppState>) -> Result {
+ let old = state.cfg();
+ for a in &old.apps {
+ webviews::destroy(&app, &a.id);
+ }
+
+ *state.config.lock().unwrap() = config::seed();
+ *state.active.lock().unwrap() = None;
+ *state.booted.lock().unwrap() = false;
+ state.unread.lock().unwrap().clear();
+ state.persist()?;
+ Ok(state.cfg())
+}
+
// ------------------------------------------------------------ passwords
/// Writes the offered login to the macOS Keychain.
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index eb154df..c3f8b7b 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -114,6 +114,7 @@ pub fn run() {
commands::delete_group,
commands::set_nav_collapsed,
commands::set_theme,
+ commands::reset_config,
commands::unread_counts,
commands::focus_window,
commands::save_password,
diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json
index 52d2d2f..2ad63b0 100644
--- a/src-tauri/tauri.conf.json
+++ b/src-tauri/tauri.conf.json
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Work",
- "version": "0.1.0",
+ "version": "0.0.1",
"identifier": "com.vincent.workapp",
"build": {
"beforeDevCommand": "npm run dev",
@@ -22,11 +22,15 @@
"hiddenTitle": true
}
],
- "security": { "csp": null }
+ "security": {
+ "csp": null
+ }
},
"bundle": {
"active": true,
- "targets": ["app"],
+ "targets": [
+ "app"
+ ],
"icon": [
"icons/32x32.png",
"icons/128x128.png",
diff --git a/src/App.tsx b/src/App.tsx
index 61199b4..ca875da 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -271,6 +271,18 @@ export default function App() {
onConfig={setConfig}
onTheme={onTheme}
onClose={() => { setSettingsOpen(false); setFocusHidden(null); }}
+ onReset={async () => {
+ // Rust closed every view and forgot which app was showing, so the
+ // shell has to build the defaults again from nothing.
+ const fresh = await api.getConfig();
+ setConfig(fresh);
+ setUnread({});
+ booted.current = false;
+ const first = [...fresh.apps].sort((a, b) => a.order - b.order)[0];
+ setActiveId(first?.id ?? null);
+ report();
+ await api.bootstrap();
+ }}
/>
)}
diff --git a/src/api.ts b/src/api.ts
index 99e656a..2a348f4 100644
--- a/src/api.ts
+++ b/src/api.ts
@@ -60,3 +60,4 @@ export const unreadCounts = () => invoke<[string, number][]>("unread_counts");
export const stageSnapshot = () => invoke("stage_snapshot");
export const savePassword = () => invoke("save_password");
export const discardPassword = () => invoke("discard_password");
+export const resetConfig = () => invoke("reset_config");
diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx
index b3c91e7..3f9a004 100644
--- a/src/components/Settings.tsx
+++ b/src/components/Settings.tsx
@@ -6,6 +6,7 @@ import type { Config, Group, WorkApp } from "../types";
import { Trash } from "./icons";
import {
BTN,
+ BTN_QUIET,
BTN_PRIMARY,
Badge,
Dialog,
@@ -29,10 +30,12 @@ interface Props {
onConfig: (c: Config) => void;
onTheme: (t: Theme) => void;
onClose: () => void;
+ /** Settings were reset; the shell has to rebuild every app's view. */
+ onReset: () => void;
}
export default function Settings({
- config, theme, activeId, focusHiddenFor, onConfig, onTheme, onClose,
+ config, theme, activeId, focusHiddenFor, onConfig, onTheme, onClose, onReset,
}: Props) {
const [name, setName] = useState("");
const [url, setUrl] = useState("");
@@ -40,6 +43,8 @@ export default function Settings({
const [groupName, setGroupName] = useState("");
const [error, setError] = useState(null);
const [confirmDelete, setConfirmDelete] = useState(null);
+ const [hiddenOpen, setHiddenOpen] = useState(false);
+ const [confirmReset, setConfirmReset] = useState(false);
const [notifyStatus, setNotifyStatus] = useState(null);
const [reports, setReports] = useState<[string, string][]>([]);
@@ -56,7 +61,7 @@ export default function Settings({
useEffect(() => {
if (!focusHiddenFor) return;
- document.getElementById("hidden-elements")?.scrollIntoView({ behavior: "smooth" });
+ setHiddenOpen(true);
}, [focusHiddenFor]);
const run = async (fn: () => Promise) => {
@@ -86,6 +91,7 @@ export default function Settings({
const withHidden = orderedApps.filter((a) => a.hidden.length > 0);
+ const totalHidden = withHidden.reduce((n, a) => n + a.hidden.length, 0);
return (
<>
@@ -170,58 +176,59 @@ export default function Settings({
+ {/* ---------------------------------------------------- groups */}
+
+ Groups
+
+ {groups.length === 0 &&
No groups yet.
}
+ {groups.map((g: Group) => (
+
+ run(() => api.updateGroup({ ...g, name: e.target.value }))}
+ className={`${INPUT} h-[26px] w-full flex-1`}
+ />
+
+ {config.apps.filter((a) => a.groupId === g.id).length} apps
+
+ run(() => api.deleteGroup(g.id))}
+ title={`Delete ${g.name} — its apps stay, ungrouped`}
+ className={`${ICON_CHROME} hover:text-red-500!`}
+ >
+
+
+
+ ))}
+
+
+
+ New group
+ setGroupName(e.target.value)}
+ onKeyDown={(e) => e.key === "Enter" && addGroup()}
+ placeholder="Finance" className={`${INPUT} w-full`} />
+
+ Add group
+
+
+
{/* ------------------------------------------- hidden elements */}
-
- Hidden elements
- {withHidden.length === 0 ? (
-
- Nothing hidden. Right-click anything in an app and choose Hide this
- element — or use the eye button in the nav to pick one.
-
- ) : (
-
- {withHidden.map((app) => (
-
-
-
- {app.name}
- {app.hidden.length}
-
-
- {app.hidden.map((sel, i) => (
-
- {
- const next = [...app.hidden];
- next[i] = e.target.value;
- run(() => api.setHidden(app.id, next));
- }}
- title="Edit the selector to widen or narrow what it hides"
- className={`${INPUT} h-[26px] w-full flex-1 font-mono text-[11px]`}
- />
-
- run(() => api.setHidden(app.id, app.hidden.filter((_, j) => j !== i)))
- }
- title="Show this again"
- className={`${ICON_CHROME} hover:text-red-500!`}
- >
-
-
-
- ))}
-
-
- ))}
-
- )}
+
+
+ Hidden elements
+ setHiddenOpen(true)} className={BTN}>
+ {totalHidden === 0 ? "None hidden" : `Manage ${totalHidden} hidden`}
+
+
- Changes apply immediately, without a reload. A selector that stops matching
- after the site changes simply hides nothing.
+ Right-click anything in an app and choose Hide this element . The
+ full list opens in its own window — there is usually a lot of it, and it is
+ not what you came to Settings for.
+
{/* ------------------------------------------------------ zoom */}
Zoom
@@ -305,43 +312,6 @@ export default function Settings({
- {/* ---------------------------------------------------- groups */}
-
- Groups
-
- {groups.length === 0 &&
No groups yet.
}
- {groups.map((g: Group) => (
-
- run(() => api.updateGroup({ ...g, name: e.target.value }))}
- className={`${INPUT} h-[26px] w-full flex-1`}
- />
-
- {config.apps.filter((a) => a.groupId === g.id).length} apps
-
- run(() => api.deleteGroup(g.id))}
- title={`Delete ${g.name} — its apps stay, ungrouped`}
- className={`${ICON_CHROME} hover:text-red-500!`}
- >
-
-
-
- ))}
-
-
-
- New group
- setGroupName(e.target.value)}
- onKeyDown={(e) => e.key === "Enter" && addGroup()}
- placeholder="Finance" className={`${INPUT} w-full`} />
-
- Add group
-
-
-
{/* ----------------------------------------------- appearance */}
Appearance
@@ -356,12 +326,91 @@ export default function Settings({
/>
-
+
+ setConfirmReset(true)}
+ title="Put apps, groups, hidden elements and zoom back to defaults"
+ className={BTN_QUIET}
+ >
+ Reset to defaults
+
Done
+ {hiddenOpen && (
+ setHiddenOpen(false)} wide>
+
+ {withHidden.length === 0 ? (
+
+ Nothing hidden. Right-click anything in an app and choose Hide this
+ element — or use the eye button in the nav to pick one.
+
+ ) : (
+
+ {withHidden.map((app) => (
+
+
+
+ {app.name}
+ {app.hidden.length}
+
+
+ {app.hidden.map((sel, i) => (
+
+ {
+ const next = [...app.hidden];
+ next[i] = e.target.value;
+ run(() => api.setHidden(app.id, next));
+ }}
+ title="Edit the selector to widen or narrow what it hides"
+ className={`${INPUT} h-[26px] w-full flex-1 font-mono text-[11px]`}
+ />
+
+ run(() => api.setHidden(app.id, app.hidden.filter((_, j) => j !== i)))
+ }
+ title="Show this again"
+ className={`${ICON_CHROME} hover:text-red-500!`}
+ >
+
+
+
+ ))}
+
+
+ ))}
+
+ )}
+
+ Changes apply immediately, without a reload. A selector that stops matching
+ after the site changes simply hides nothing.
+
+
+
+ )}
+
+ {confirmReset && (
+ setConfirmReset(false)}
+ onConfirm={async () => {
+ await run(() => api.resetConfig());
+ setConfirmReset(false);
+ onReset();
+ }}
+ confirmLabel="Reset"
+ destructive
+ >
+ Your apps, groups, hidden elements and zoom go back to the defaults. You stay
+ signed in to everything — sessions live in the browser engine's own store, not
+ in this app's settings, and are not touched.
+
+ )}
+
{confirmDelete && (