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 + + +
+ ))} +
+
+ + +
+
+ {/* ------------------------------------------- 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]`} - /> - -
- ))} -
-
- ))} -
- )} +
+
+ Hidden elements + +

- 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 - - -
- ))} -
-
- - -
-
- {/* ----------------------------------------------- appearance */}
Appearance @@ -356,12 +326,91 @@ export default function Settings({ />
-
+
+
+ {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]`} + /> + +
+ ))} +
+
+ ))} +
+ )} +

+ 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 && (