29376 Commits
Author SHA1 Message Date
Isaac Connor 17774364bc Merge pull request #5150 from IgorA100/patch-622059
Fix: Avoiding errors when changing the "src" attribute during playback with go2rtc (video-stream.js)
2026-09-19 17:02:56 -04:00
IgorA100 f1b89471e4 Avoiding errors when changing the "src" attribute during playback with go2rtc "video-stream.js"
This can happen, for example, on the Console page when hovering over an image thumbnail, because the `video-stream` element created for go2rtc in `createGo2rtcStream()` is not assigned an ID.
2026-09-19 23:46:24 +03:00
Isaac Connor e0a619d3c9 Merge pull request #4980 from IgorA100/patch-756163
Start loop countdown only if the stream has started playing on the Watch page.
2026-09-19 10:20:10 -04:00
Isaac Connor 181ece7150 Merge pull request #5149 from IgorA100/patch-641284
Fix: PanZoom scaling on mouse click.
2026-09-19 10:17:45 -04:00
IgorA100 d27f4893e5 Merge branch 'master' into patch-641284 2026-09-19 11:39:50 +03:00
Isaac Connor 54694d6bba Merge branch 'master' of github.com:ZoneMinder/zoneminder 2026-09-18 19:42:25 -04:00
Isaac ConnorandClaude Opus 5 4a02f11e63 fix: tidy the filter view's email settings refs #5147
html_radio() emits <div>s, and a <div> closes an open <p>, so the Email
Format radios were parsed out of their row and landed on the line below
their label. Wrap that row in a div instead. The row label's width now
applies to direct children only, so the radios' own labels keep their
natural size.

Separate the email settings from the options above them with a rule,
rather than having Email To run straight on from Lock Rows.

zmfilter.pl falls back to ZM_EMAIL_HOST when a filter names no server, so
show that (or localhost, its default) as the Email Server placeholder
instead of leaving the field looking unset.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 19:04:04 -04:00
Isaac ConnorandClaude Opus 5 86952cc0de fix: give the log panel its own strings refs #5147
The panel's messages came from the global translate object that
views/js/log.js.php defined, which the footer only loads for the Log
view. On any other view Clear Logs threw "translate is not defined" out
of deleteLogs() before it sent anything, and a failed table query would
have thrown the same way.

Carry the five strings on the panel element as data-i18n and read them
in initLogPanel(), so a panel is self-contained wherever it is embedded.
views/js/log.js.php held nothing else, so it goes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 19:04:04 -04:00
Isaac ConnorandClaude Opus 5 400464a23b fix: lift the Filter log legend clear of the form's rule refs #5147
The legend sat directly on the line the form draws below its buttons.
A fieldset's legend renders in the border box, above the padding, so
padding-top does not move it; give the fieldset a top margin instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 19:04:04 -04:00
Isaac ConnorandClaude Opus 5 f270369fe4 fix: stop the filter form's checkboxes and height being squeezed refs #5147
The Actions and Options rows are flex lines, so a checkbox sat next to a
growing text input or select was shrinkable and rendered 16.5px against
the 18px of the rows that have nothing after them -- visible on Execute
command on all matches, and on Copy/Move once their storage select is
shown. Take the checkboxes out of the shrinking.

#content is a flex column, so once the log panel became a second child
the form shrank below its content and, having overflow:auto from
skin.css, grew a scrollbar of its own that cut the Actions list in half.
Let the form keep its height and leave the scrolling to #content, which
now shows the whole view in one scroller.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 19:04:04 -04:00
Isaac ConnorandClaude Opus 5 73428559a2 fix: put the top navbar back to the height it was in 1.38 refs #5148
renderMenuIcon() emits the material default of 24px, so every top navbar
item was 40px tall rather than the 36px the bar was before it had icons,
and the menu was wide enough that the collapse toggle wrapped onto a row
of its own: 76px of header where 1.38.4 used 55px, on every view.

Render the navbar's menu icons at 18px, which is the size the statistics
bar below already uses, and lay each link out as a flex row so the icon
and its label stay centred on each other. Drop the collapse toggle's
1rem side padding, which was 32px of the width that caused the wrap.

#navbar-container is 55px again, matching 1.38.4 exactly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 19:04:04 -04:00
Isaac ConnorandClaude Opus 5 e8e51743ab feat: give the filter view the full log panel refs #5147
Replace the static 50-row table with the panel the Log view uses, locked
to this filter's component. That brings the date range, level and server
filters, search, sorting, paging, the column chooser and export, and
lets a System editor clear the rows it lists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 19:04:04 -04:00
Isaac ConnorandClaude Opus 5 1be3378faa refactor: extract the log toolbar and table into a reusable panel refs #5147
The Log view was the only way to read a log, and its toolbar, its
bootstrap-table and the script behind them were written into the view, so
a second view could only have a log by copying all three.

Move the markup to skins/classic/includes/logpanel.php as
getLogPanelHTML(), and the behaviour to skins/classic/js/logpanel.js,
which picks up every .logPanel in the page and drives it. The script
keeps its request state, its selection and its in-flight XHR in a
closure, so two panels on one page no longer share them; the table gets
its ajax function from that closure instead of the global that
data-ajax="ajaxRequest" resolved to. Options cover what the call sites
differ on: the element id prefix, a fixed set of components, the page
size and whether the Back and Refresh page buttons are shown.

A panel locked to components sends embedded=1, and ajax/log.php then
leaves zmLogComponent and zmLogFilterLevel alone, so an embedded panel
cannot overwrite what the user chose in the Log view. Such a panel also
drops the component selector and column, and the unfiltered total, which
counts every log row and means nothing once the panel is scoped.

The level colours and the table's column rules move from the per-view
log.css files to the matching skin.css, since they now apply wherever a
panel is embedded.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 19:04:04 -04:00
Isaac ConnorandClaude Opus 5 efccc00d4d feat: list the selected filter's log entries on the filter view refs #5147
Both the background daemons (zmpkg starts one zmfilter.pl per background
filter) and the Execute button run "zmfilter.pl --filter_id=N", and
zmfilter.pl calls logInit(id => 'zmfilter_N'), so the Logs rows with that
component are exactly the selected filter's output. Until now the only
way to read them was the Log view, where they had to be picked out of
every other component.

Show the 50 most recent under a Filter Log section at the bottom of the
view, for a saved filter and a user who may view System. The section is
rendered with the page rather than fetched, because every action that
writes to this log -- Execute, Save, switching filters -- reloads the
view anyway. Level colours are duplicated from log.css, which is not
loaded here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 19:04:04 -04:00
Isaac ConnorandClaude Opus 5 201e1c0406 fix: lay out the filter edit view with flexbox refs #5147
Actions and Options were floated halves with a clearfix and a fixed
320px label width. That left a wide gutter between every label and its
checkbox, pushed the AutoExecuteCmd input onto its own line at the far
left, and overflowed the Options column so the "seconds" after Execute
Interval was clipped. Make the pair a wrapping flex row, centre a
max-content block in each column, and lay each row out as a flex line so
labels stay right aligned against controls that follow inline.

skin.css sets .filterTable { display: flex } for the <div> variant on
the events toolbar. The filter view's elements really are tables, so the
rule blockified their tbody and left #sortTable aligned to the left edge
instead of centred. Restore table layout for table.filterTable.

Also give the term value column a width and let its input fill the cell
so rows line up whichever attribute is selected, replace the 500px
min-widths on the filter selector and the Name input with
min(500px, 100%) so narrow screens no longer scroll sideways, and let
#contentButtons wrap rather than float and clip its last buttons.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 19:04:04 -04:00
Isaac ConnorandClaude Opus 5 86067e8a2b feat: group the filter query fields and unnest the email format radios refs #5147
The term rows and the sort/limit row sat above Actions and Options with
no heading of their own, so the top half of the form read as loose
controls. Wrap them in a fieldset with a Query legend to match the two
sections below it.

The EmailFormat radios were rendered inside the <label> that titles
them, which made the label the control's own click target and left the
radios unstyleable as a row. Close the label before the radios and mark
the paragraph with an EmailFormat class.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 19:04:04 -04:00
Isaac ConnorandClaude Opus 5 95c11b6f01 fix: close the filter terms table markup refs #5147
Filter::widget() opened <table id="fieldsTable"><tbody> and returned
without closing either. On the filter view the parser recovered because
the next sibling was another <table>, but on the events page the
following </div> closed the toolbar column while the table stayed open,
so the remaining toolbar columns were foster-parented out of place.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 19:04:04 -04:00
IgorA100 ea60457df6 Merge branch 'master' into patch-756163 2026-09-18 23:47:48 +03:00
IgorA100 6d459cb591 Fix: PanZoom scaling on mouse click.
We are modifying the `thisClickOnStreamObject()` function to account for the fact that the browser might register the click on the `.zoompan` wrapper.
2026-09-18 23:42:14 +03:00
Isaac Connor a5b2f99a2f Merge pull request #5138 from connortechnology/5135-cycle-interval-leak
fix: stop the cycle timer leaking an interval on every restore
2026-09-15 18:21:33 -04:00
Isaac Connor a32cce67f5 Merge pull request #5140 from ZoneMinder/dependabot/github_actions/github/codeql-action-4.38.0
build(deps): bump github/codeql-action from 4.37.9 to 4.38.0
2026-09-15 18:16:07 -04:00
dependabot[bot] fa000bc365 build(deps): bump github/codeql-action from 4.37.9 to 4.38.0
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4.37.9 to 4.38.0.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/v4.37.9...v4.38.0)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-version: 4.38.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-14 17:43:44 +00:00
IgorA100 7e4159da81 Merge branch 'master' into patch-756163 2026-09-13 23:38:02 +03:00
Isaac ConnorandClaude Opus 5 599627a940 fix: stop the cycle timer leaking an interval on every restore fixes #5135
cycleStart() took a fresh setInterval id straight into cycleIntervalId without
clearing what was already there. Once overwritten the old id is unrecoverable,
so the orphan keeps calling nextCycleView every second and cyclePause() can only
ever stop the last one armed.

Several callers reach cycleStart() with no cyclePause() in between: the play
button, the are-you-still-watching modal closing, and startPage(). That last one
is the routine path - it runs from visibilitychange, from resume and from
pageshow, and a restore fires more than one of those, so a tab coming back while
cycling was active armed two. The monitor restart in the same function is
protected, since it nulls prevStateStarted on the way through, but the cycle
branch below it never cleared prevStateCycle.

Clear the interval at the top of cycleStart(), which makes every caller safe
whatever order they arrive in, and clear prevStateCycle in startPage() the way
prevStateStarted already is. cycle.js has the same shape in its own cycleStart()
behind a play button, so it gets the same guard.

Tests in tests/js/watch-cycle-interval.test.js drive watch.js under stubbed
timers and count what is left running: two starts leave one interval, a pause
after three starts leaves none, and a second startPage() does not re-arm. Three
of the four fail against the unfixed file.

Full JS suite passes, ESLint clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UkQwahn9pi1y4wJe9BTxjM
2026-09-13 11:51:26 -04:00
Isaac Connor 5c71bf5aba Merge pull request #5093 from ZoneMinder/refresh-auth-on-refocus
fix: refresh auth hash before restarting streams after a long tab hide
2026-09-13 11:45:54 -04:00
Isaac ConnorandClaude Opus 5 da99928235 fix: revalidate on every resume rather than trusting a time window
The staleness window was unsound. calculateAuthHash() keys the hash to the
clock hour it was minted in and getAuthUser() accepts the last
ZM_AUTH_HASH_TTL hourly buckets, so a hash dies at the top of an hour rather
than at some age. generateAuthHash() then serves the cached one until it is
half a TTL old, so what arrives can already be nearly spent: on the defaults a
hash minted at 10:59 is still handed out at 11:58 and is refused at 12:00. A
client stamping that arrival as fresh for an hour skips the probe until 12:58
and restarts its streams on a dead hash - the exact failure this was written to
prevent. No fixed window is safe, because the remaining life of a hash we hold
can be anything down to zero, and AUTH_STALE_MS also ignored the configured
ZM_AUTH_HASH_TTL.

So drop AUTH_STALE_MS, authIsStale() and authFreshAt, and have whenAuthFresh()
revalidate. The one case that can still skip the probe is having no hash at all
- authentication off, or a relay form that does not use one - where there is
nothing that can expire and nothing a probe would report. revalidateAuth()
already shares one request between concurrent callers, so a resume that wakes
several of these still costs a single probe, and that is what the montage code
did unconditionally before any of this.

refreshTablesPendingVisibility() now returns as soon as it finds nothing was
deferred. It is bound on every classic page including the unauthenticated ones,
and the version before this ran the whole auth path on an empty queue, so
merely becoming visible could fire a probe with no work behind it.

Tests: two authIsStale cases removed with the function, two whenAuthFresh cases
added - a probe is sent and the callback held until it answers, and no probe is
sent when there is no hash. Reintroducing a fast path fails the first. Full JS
suite green, ESLint clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UkQwahn9pi1y4wJe9BTxjM
2026-09-13 10:37:02 -04:00
Isaac ConnorandClaude Opus 5 4c878eeebe fix: send the auth revalidation probe without a credential
The probe went out as zmAuth.appendTo(...), carrying the very hash it existed to
replace. zm_authenticate_request() resolves a request against exactly one source:
a non-empty auth= in the URL enters the ZM_AUTH_HASH_LOGINS branch (on by
default), and when getAuthUser() rejects it the chain has already been taken, so
the userFromSession() arm below it never runs. A live session cookie then
authenticates as nobody.

Past ZM_AUTH_HASH_TTL - a tab hidden longer than two hours on the defaults, which
is exactly the case this change is for - the probe was therefore the one request
guaranteed to fail, and its failure is read as 'login', so the user was bounced
to the login page with a perfectly good session. That is worse than the 403s in
the log this set out to remove.

Send the probe bare. The session cookie is what answers, which is the question
being asked: who am I, and what is my current hash?

That also makes the failure handling mean what its comment claimed. A rejection
now really is a dead session rather than a dead hash, so redirecting to login on
it is right - and both 401 and 403 reach it, which the comment now says.

Also correct the AUTH_STALE_MS comment: authIsStale() is a strict comparison, so
a credential confirmed exactly AUTH_STALE_MS ago is still fresh, as the test
asserts.

Tests: tests/js/auth-helpers.test.js, 51 passed (4 new for revalidateAuth,
covering the bare probe, shared in-flight request, callbacks surviving a
transient failure, and login on a rejected session). Reverting the probe to the
credentialed form fails two of them.

refs #5093

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nr76CednxtDt2nPuq6WrbL
2026-09-13 00:03:13 -04:00
Isaac ConnorandClaude Opus 5 1d01c966d4 refactor: track credential age instead of hidden time, fold montage in
whenAuthFresh() gated on how long the tab had been hidden, which only covers
one of the ways the page stops hearing about the credential. montage's idle
timeout is another: hitting ZM_WEB_VIEWING_TIMEOUT stops every monitor, and
their status polls with them, while the tab stays visible the whole time. The
Are You Still Watching modal can then sit there for hours, so the auth hash
baked into the monitor srcs is just as dead as after an overnight sleep, and
a hidden-time gate would wave it straight through.

Track the age of the credential itself instead. ZMAuth.update() stamps it
whenever a reply carries auth_relay or auth - even when the value is
unchanged, since the server has still just confirmed it - and revalidateAuth()
stamps it too, so authentication being off doesn't leave every caller
revalidating once an hour forever. authHiddenTooLong(hiddenAt, now) becomes
authIsStale(freshAt, now); onAuthVisible() no longer keeps a timestamp.

That subsumes montage's refreshAuthAndStartMonitors(), which duplicated
revalidateAuth()'s navBar probe and fired a second one on every refocus.
Both call sites are now whenAuthFresh(startVisibleMonitors), which shares
the in-flight probe rather than racing it.

Tested: node tests/js/auth-helpers.test.js (47 passed), node
tests/js/table-helpers.test.js (8 passed), npx eslint on the changed files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JkE45gkMjnkTiJiUySbe7V
2026-09-13 00:03:13 -04:00
Isaac ConnorandClaude Opus 5 c83a15e048 fix: refresh auth hash before restarting streams after a long tab hide
Hidden tabs have their timers throttled, and frozen/slept ones stopped
outright, so nothing refreshes the credential while the tab is in the
background. The server rotates the auth hash at half of AUTH_HASH_TTL
(generateAuthHash()), so the hash baked into a stream <img> src or a
deferred table url is usually dead by the time the tab is refocused. Every
restarted stream and table poll then fires a request that 403s and logs an
auth error before anything gets around to revalidating.

Record when the tab goes hidden and treat the credential as stale once more
than an hour has passed. Add whenAuthFresh(cb), which runs cb straight away
after a short alt-tab but queues it behind a revalidation after a long
sleep, so nothing makes an authenticated request on the expired hash.

revalidateAuth() now queues callbacks rather than dropping them when a probe
is already in flight, clears the hidden timestamp only once a reply actually
arrives, and still runs the queued callbacks on a transient failure - a
network blip is no reason to leave the page's streams stopped. A 401 still
redirects to login and drops the queue.

Gate the two visibility-driven callers on it: watch.js startPage() and
table-helpers.js refreshTablesPendingVisibility(). montage.js already
refreshes auth before restarting its monitors.

Tested: node tests/js/auth-helpers.test.js (48 passed), node
tests/js/table-helpers.test.js (8 passed), npx eslint on the changed files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JkE45gkMjnkTiJiUySbe7V
2026-09-13 00:03:13 -04:00
Isaac Connor 4061264597 Merge pull request #5134 from connortechnology/fix-max-age-comment
docs: correct what max-age=2147483647 actually means in setCookie
2026-09-13 00:02:06 -04:00
Isaac Connor 4d79e0ad55 Merge pull request #5136 from SteveGilvarry/macos-docs-corrections
docs: correct the macOS guide against a real install
2026-09-13 00:01:25 -04:00
Isaac Connor 91c1ed4a07 Merge pull request #5137 from SteveGilvarry/configurable-script-path
fix: make the Perl scripts' taint-safe PATH configurable
2026-09-13 00:00:54 -04:00
Steve GilvarryandClaude Opus 5 5d92a58c81 fix: do not auto-add a group writable directory to the script PATH
Auto-appending wherever a database client was found widens who can
decide what the daemons execute, and perl will not catch it: its taint
check refuses a world writable directory on PATH but permits a group
writable one.

  world writable 0777 -> Insecure directory in $ENV{PATH}
  group writable 0775 -> accepted

That is not theoretical. Homebrew's bin is 0775 owned by group admin, so
the previous commit would have silently handed every admin user the
ability to place a mysql binary that the ZoneMinder daemons run.

Check the directory before adding it, and when it is writable by group
or world, leave it out and say why. An explicit ZM_SCRIPT_PATH still
wins, so anyone who accepts that risk can still opt in - the difference
is that it is now a decision rather than a default.

Verified on macOS: the real Homebrew bin is refused with the warning, a
0755 directory is still appended, and -DZM_SCRIPT_PATH= overrides both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5KL9Xbi7K5aGsauLtd8tG
2026-09-13 13:20:56 +10:00
Steve GilvarryandClaude Opus 5 ba49bbb33c fix: make the Perl scripts' taint-safe PATH configurable
The scripts run under -T, so they cannot trust the caller's PATH and set
their own. Sixteen of them hardcoded /bin:/usr/bin:/usr/local/bin, which
assumes everything they shell out to lives under /usr or /usr/local.

ZoneMinder::General::findDbCommand looks for a database client on that
PATH, and zmupdate.pl and zmcamtool.pl run what it finds. So an install
whose client sits anywhere else cannot apply schema changes:

  sh: mysql: command not found
  Command 'mysql -u'zmuser' ... ' exited with status: 127

even with the client on the caller's PATH. Homebrew on Apple Silicon is
the case that surfaced it - the client is in /opt/homebrew/bin - but a
--prefix=/opt install on Linux has the same shape, as does anything that
keeps its database client outside the FHS locations.

Replaced the literal with @ZM_SCRIPT_PATH@, defaulting to the same three
directories plus wherever cmake actually found a client, and overridable
for packagers who want to pin it. Warn at configure time when no client
is found at all, since that failure otherwise appears much later and
says something unrelated.

Nothing changes for an install whose client is already under /usr/bin:
the directory is only appended when it is not in the list, so the
default stays exactly as it was.

Memory.pm is deliberately left alone. It has a narrower PATH of
/bin:/usr/bin, and the only command it runs is uname through an absolute
path from ZM_PATH_UNAME, so it does not need widening.

Verified on macOS across the three cases: with the client in
/opt/homebrew/bin the directory is appended; -DZM_SCRIPT_PATH= is
respected verbatim; and pointing detection at /usr/bin/mariadb leaves
the default untouched. Build clean, suite 146 cases.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5KL9Xbi7K5aGsauLtd8tG
2026-09-13 13:04:59 +10:00
Steve GilvarryandClaude Opus 5 be9a98d856 docs: correct the macOS guide against a real install refs #4998
The guide was written from a verified build and install, but the steps
past that point were derived from the Debian packaging rather than run.
Working through them on Apple Silicon to a running system turned up
three things that were wrong or missing.

mysql -u root does not work. A Homebrew MariaDB authenticates root with
the unix_socket plugin, so only the operating system's root matches it;
your own account gets the administrative account, which is what plain
mariadb uses.

Nothing said ZoneMinder has to be started as ZM_WEB_USER. zmpkg.pl
compares the current user against it and tries sudo -u and two forms of
su to become it, all of which fail for an ordinary user, and stops with
"Unable to find valid su syntax". systemd hides this on Linux by
starting the unit as the web user. Now says so, and gives the
workstation alternative of running everything as one account.

The web server section did not mention that zms is a CGI binary. Apache
runs those with mod_cgi; nginx needs a wrapper such as fcgiwrap, which
Homebrew does not package, so with nginx alone the console works and
live streams do not. Also noted that the generated samples reference
/etc/pki, /etc/nginx and /run/fcgiwrap.sock, none of which exist here.

Added the hardcoded taint-safe PATH to the known gaps, since it is what
breaks zmupdate.pl on Apple Silicon: seventeen scripts pin
/bin:/usr/bin:/usr/local/bin, so the database client in the Homebrew
prefix cannot be found. Worth making configurable - it would equally
affect a --prefix=/opt install on Linux. Sys::CPU is listed too; it has
been removed from CPAN and only zmtelemetry.pl uses it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5KL9Xbi7K5aGsauLtd8tG
2026-09-13 12:50:36 +10:00
Isaac ConnorandClaude Opus 5 528d95a284 docs: correct what max-age=2147483647 actually means in setCookie
The comment said 2147483647 "is January of 2038 to avoid the 32bit integer
overflow bug". Max-Age is a duration in seconds counted from now, not an
absolute date (RFC 6265 section 5.2.2), so the value is roughly 68 years out.
January 2038 is what 2^31 - 1 means read as an epoch timestamp, which is how
Expires works, not Max-Age. The number is still the right choice, for the
reason the comment half stated: it is the largest value that cannot overflow a
signed 32bit int in a client that converts it.

The sessionCookies comment above repeated the same claim as "kept until 2038".
That one matters beyond pedantry, since the argument for scoping those cookies
to the session rests on how long the default keeps them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UkQwahn9pi1y4wJe9BTxjM
2026-09-12 18:46:29 -04:00
Isaac Connor 436ac9b3cf Merge pull request #5120 from ZoneMinder/4706-zms-orphan-cleanup
fix: stop orphaned zms processes accumulating, without killing streams on tab hide
2026-09-12 18:18:46 -04:00
Isaac ConnorandClaude Opus 5 1361f93804 fix: send the initial mode=paused CMD_PLAY that streamCommand was dropping
select_zms() has three ways out, and two of them send a stream command before
the tail of the function sets started. streamCommand() drops anything sent while
that is false, so such a branch reports success having sent nothing and the
picture sits on its last keepalive frame.

The resume branch was fixed on this branch already. The branch below it, for a
page rendered with mode=paused, has the same shape and was missed. With auth on
it needed a still-valid hash to reach, so it was intermittent; with auth off,
where there is no hash that can go stale, the srcAuthCurrent change on this
branch makes it the path every initial load takes. Set started there too.

Add tests/js/monitorstream-resume.test.js, which asserts what reaches the wire
rather than which branch ran: the resume and initial-paused paths each send
exactly one CMD_PLAY on the connkey they are supposed to address, resuming
leaves src and connkey alone, and a stale auth hash still rebuilds and quits the
process the old connkey addressed. Removing the one-line fix fails the
initial-paused case and leaves the other three passing.

Full JS suite passes, ESLint clean on both files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UkQwahn9pi1y4wJe9BTxjM
2026-09-12 15:51:14 -04:00
Isaac Connor ace41f512f Merge pull request #5133 from connortechnology/ci-unique-package-versions
ci: make package versions unique per build, and build once per branch
2026-09-12 15:41:06 -04:00
Isaac ConnorandClaude Opus 5 f46cc1bcd8 fix: release held frames and set the terminate flag on every stream exit
Two problems on the exit paths out of the runStream loop, both raised in review.

paused_image and stopped_image were raw pointers freed only by a later iteration
observing that the state had changed. Any break leaves the function with one
still allocated: the stopped-state ttl break added by this branch, and the ttl
and frames_to_send breaks at the bottom of the loop that predate it. Hold both
in unique_ptr so the exit path stops mattering.

More seriously, those breaks leave zm_terminate false. StreamBase::checkCommandQueue
loops on !zm_terminate around a recvfrom with a one second SO_RCVTIMEO, so it
returns only once that flag is set, and runStream joins it unconditionally when
connkey is set. A stream that ended on its ttl or its frame count therefore sat
in join() rather than exiting, which is another way for a zms to outlive its
client. Set the flag on all three.

The two sendFrame failure breaks and the feof/ferror breaks already did this,
which is what the pattern should have been throughout.

Verified by reading: checkCommandQueue's only loop condition is the flag. Not
reproduced end to end, because no zmc is running on this machine and a stream
against a monitor with no capture takes the not-initialised path instead, which
already sets the flag correctly on its own timeout.

zms builds clean with no new warnings. C++ suite 133/133 on this branch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UkQwahn9pi1y4wJe9BTxjM
2026-09-12 12:44:55 -04:00
Isaac ConnorandClaude Opus 5 6514b1086a fix: resume a stopped zms instead of abandoning it and building another
select_zms() has a branch that resumes an existing zms with CMD_PLAY rather
than rebuilding the stream, but three things stopped it ever working. Every one
of them showed up only when the page was driven for real.

stop() ends by clearing activePlayer, which is the only thing that branch
tests, so after any stop it was unreachable and start() fell through to "new
src, new connkey" - a second zms, the first left running and no longer
addressable. That is the montage page's accumulating processes: hide the tab,
the handler stops each stream, and returning replaces rather than resumes. The
same happens when a monitor is scrolled out of view and back. stop() now
remembers what it shut down, and select_zms() resumes on that as well as on
activePlayer, provided we still hold the connkey to address it.

srcAuthCurrent required a non-empty zmAuth.hash, which is '' whenever auth is
off or the relay carries no hash. There is nothing that can go stale in that
case, so the src is as current as it will ever be; requiring a hash sent every
such install down the rebuild path for no reason.

streamCommand() drops anything sent while !started, and started is not set
until the end of select_zms(), so the resume issued its CMD_PLAY into nothing
and the stream stayed stopped. Resuming after a pause worked only because
pause() leaves started set. It is now set before the command goes out.

Finally, the rebuild path clears the "Loading..." info block from img_onload,
which cannot fire on a resume because src never changes. Without clearing it,
a stream that had in fact resumed sat behind that block and its still image and
looked frozen - the fault that made this look unfixable at first.

restart() is excluded from all of it: it is the error path, whatever failed may
be that very zms, and a broken img is not repaired by CMD_PLAY.

Verified on a live montage page with two monitors: across two hide/show cycles
the same two zms processes are kept - no orphans, no respawn - and both
pictures are live afterwards, with the zms status reporting stopped=0 and
~15 fps.

refs #4706

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 12:44:54 -04:00
Isaac ConnorandClaude Opus 5 a6515ae25b fix: quit the old zms before replacing the connkey that addresses it
select_zms() mints a fresh connkey whenever it has to rebuild the stream src,
without telling the process the old connkey belonged to that it is finished.
Once the key is replaced nothing can reach that process again: no CMD_QUIT can
be delivered, and a stopped one will not notice on its own.

getStreamCmdResponse() already learned this - its reload path calls
quitConnKey() first, with a comment saying why - but the path every ordinary
start() takes did not. quitConnKey had exactly two references in the file: its
own definition and that one use.

refs #4706

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 12:44:54 -04:00
Isaac ConnorandClaude Opus 5 2958b1047a fix: let a stopped zms notice a departed client and honour its ttl
A zms in the stopped state could never exit. The branch handling it slept and
continued without writing anything, so the socket was never touched and a
client that had gone away was never discovered - no write, no EPIPE, no
SIGPIPE. The continue also skipped the ttl check at the bottom of the loop, so
even a deadline set by the caller did not apply. The only way out was an
explicit CMD_QUIT, and any stream that missed one stayed until the machine was
restarted.

That is what accumulates on the montage page: the tab is hidden, the browser
side sends CMD_STOP, and if the stream is later replaced rather than resumed,
nothing can address the old process again and it sleeps forever.

Hold the last captured frame on entering the stopped state and re-send it
every five seconds, the way the paused state already does, and apply ttl here
too. The frame is what makes a departed client detectable; it also keeps the
connection open for the resume that stopped is meant to allow, which is what
the state was documented to do. Where nothing has been captured yet there is
no frame to hold, so a text frame is sent instead - the write matters more
than what is in it.

setLastViewed is deliberately still not called: capture and decoding should not
be held active for a stream that is not playing.

refs #4706

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 12:44:54 -04:00
Isaac ConnorandClaude Opus 5 c1803c1096 ci: build one package set per branch instead of one per push
Three merges landed within 45 seconds on 2026-09-12 and each started its own
run of both package workflows. Every run publishes, so a burst of merges puts
several uploads into the repo for what is effectively one state of the tree.

Add a concurrency group per workflow and branch with cancel-in-progress, so a
newer push supersedes a build still in flight rather than racing it into the
incoming directory. This also stops the matrix rebuilding fourteen containers
for a commit that is already stale.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UkQwahn9pi1y4wJe9BTxjM
2026-09-12 11:54:25 -04:00
Isaac ConnorandClaude Opus 5 d8353aa3c3 ci: make package versions unique per build
The snapshot component is date plus the number of commits since version.txt
last changed, so every build of the same tip on the same day produces the same
version string. The repo operator reports 96% of version+dist pairs published
more than once, and 1.39.34~20260912.22-bookworm1 uploaded six times in about
half an hour, each upload overwriting the last with different bytes.

Nothing on the repo server can close that. Reindexing shrinks the window but a
client that read Packages before a republish and fetched the .deb after it still
gets a hash mismatch, because the version no longer identifies the content.

Append GITHUB_RUN_ID and GITHUB_RUN_ATTEMPT, which are unique per build and
never reused. Outside Actions the string is unchanged, so a local build still
produces the version it always did.

Checked that the longer string still sorts after the versions already published
and before the next version.txt bump, with dpkg --compare-versions and
rpm.vercmp.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UkQwahn9pi1y4wJe9BTxjM
2026-09-12 11:54:25 -04:00
Isaac Connor 0487e7fad7 Merge pull request #5126 from connortechnology/5122-onvif-case-collision
fix: remove ZoneMinder::Control::onvif, it collides with ONVIF.pm
2026-09-12 10:24:47 -04:00
Isaac Connor 76002987f9 Merge pull request #5128 from connortechnology/5127-packetqueue-iterator-leak
fix: free the event start iterator when openEvent cannot lock its packet
2026-09-12 10:24:25 -04:00
Isaac Connor 0a4194d5e4 Merge pull request #5132 from SteveGilvarry/4998-macos-native-build
Build, test and install ZoneMinder natively on macOS
2026-09-12 10:24:03 -04:00
Steve GilvarryandClaude Opus 5 9ef8c2a17c docs: describe the launchd job in the macOS guide refs #4998
Replaces the "there is no launchd job yet" note with how to install and
load the generated one, and why unloading it does not stop ZoneMinder.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5KL9Xbi7K5aGsauLtd8tG
2026-09-12 22:19:09 +10:00
Steve GilvarryandClaude Opus 5 8de8ed4018 feat: add a launchd job for starting ZoneMinder on macOS refs #4998
misc/ has a systemd unit and an init.d script, so on macOS nothing
restarts ZoneMinder after a reboot. This is the launchd counterpart,
generated next to zoneminder.service with the same substitutions.

Mapping the unit across needed three decisions worth recording.

zmpkg.pl forks zmdc.pl and returns, which systemd calls Type=forking.
launchd has no such notion and by default kills everything left in a
job's process group once the job exits - which would be all of
ZoneMinder, immediately. AbandonProcessGroup=true disables exactly that.
So the job starts ZoneMinder rather than supervising it, and zmdc.pl and
zmwatch.pl go on restarting the daemons themselves as they always have.

There is no equivalent of ExecStop, so unloading the job does not stop
ZoneMinder. The comment in the file says so and gives the stop-then-
unload order rather than leaving people to find out.

There is no equivalent of After= or Requires= either. launchd only
orders jobs it manages, and a Homebrew MariaDB is not one, so ZoneMinder
can start before the database is listening. zmdc.pl retries, and the
comment points at zmdc.log for when it matters.

Generated but not installed, matching how zoneminder.service is treated:
/Library/LaunchDaemons is read at boot, so putting a job there is the
installer's decision and not the build's.

Verified with plutil -lint and plutil -p on the generated file, and every
key checked against launchd.plist(5). Not loaded here - that needs an
installed ZoneMinder to start.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5KL9Xbi7K5aGsauLtd8tG
2026-09-12 22:19:09 +10:00