ViewVC Help
View File | Revision Log | Show Annotations | Download File
/cvs/cvsroot/Net-FCP/FCP.pm
(Generate patch)

Comparing cvsroot/Net-FCP/FCP.pm (file contents):
Revision 1.19 by root, Sun Sep 14 09:48:01 2003 UTC vs.
Revision 1.29 by root, Thu May 13 21:43:16 2004 UTC

34 34
35The import tag to use is named C<event=xyz>, e.g. C<event=Event>, 35The import tag to use is named C<event=xyz>, e.g. C<event=Event>,
36C<event=Glib> etc. 36C<event=Glib> etc.
37 37
38You should specify the event module to use only in the main program. 38You should specify the event module to use only in the main program.
39
40If no event model has been specified, FCP tries to autodetect it on first
41use (e.g. first transaction), in this order: Coro, Event, Glib, Tk.
39 42
40=head2 FREENET BASICS 43=head2 FREENET BASICS
41 44
42Ok, this section will not explain any freenet basics to you, just some 45Ok, this section will not explain any freenet basics to you, just some
43problems I found that you might want to avoid: 46problems I found that you might want to avoid:
69 72
70package Net::FCP; 73package Net::FCP;
71 74
72use Carp; 75use Carp;
73 76
74$VERSION = 0.08; 77$VERSION = 0.6;
75 78
76no warnings; 79no warnings;
77 80
78our $EVENT = Net::FCP::Event::Auto::; 81our $EVENT = Net::FCP::Event::Auto::;
79$EVENT = Net::FCP::Event::Event;#d#
80 82
81sub import { 83sub import {
82 shift; 84 shift;
83 85
84 for (@_) { 86 for (@_) {
85 if (/^event=(\w+)$/) { 87 if (/^event=(\w+)$/) {
86 $EVENT = "Net::FCP::Event::$1"; 88 $EVENT = "Net::FCP::Event::$1";
89 eval "require $EVENT";
87 } 90 }
88 } 91 }
89 eval "require $EVENT";
90 die $@ if $@; 92 die $@ if $@;
91} 93}
92 94
93sub touc($) { 95sub touc($) {
94 local $_ = shift; 96 local $_ = shift;
97 $_; 99 $_;
98} 100}
99 101
100sub tolc($) { 102sub tolc($) {
101 local $_ = shift; 103 local $_ = shift;
104 1 while s/(SVK|CHK|URI)([^_])/$1\_$2/i;
105 1 while s/([^_])(SVK|CHK|URI)/$1\_$2/i;
102 s/(?<=[a-z])(?=[A-Z])/_/g; 106 s/(?<=[a-z])(?=[A-Z])/_/g;
103 lc $_; 107 lc $_;
104} 108}
105 109
110# the opposite of hex
111sub xeh($) {
112 sprintf "%x", $_[0];
113}
114
106=item $meta = Net::FCP::parse_metadata $string 115=item $meta = Net::FCP::parse_metadata $string
107 116
108Parse a metadata string and return it. 117Parse a metadata string and return it.
109 118
110The metadata will be a hashref with key C<version> (containing 119The metadata will be a hashref with key C<version> (containing the
111the mandatory version header entries). 120mandatory version header entries) and key C<raw> containing the original
121metadata string.
112 122
113All other headers are represented by arrayrefs (they can be repeated). 123All other headers are represented by arrayrefs (they can be repeated).
114 124
115Since this is confusing, here is a rather verbose example of a parsed 125Since this description is confusing, here is a rather verbose example of a
116manifest: 126parsed manifest:
117 127
118 ( 128 (
129 raw => "Version...",
119 version => { revision => 1 }, 130 version => { revision => 1 },
120 document => [ 131 document => [
121 { 132 {
122 info => { format" => "image/jpeg" }, 133 info => { format" => "image/jpeg" },
123 name => "background.jpg", 134 name => "background.jpg",
136 ) 147 )
137 148
138=cut 149=cut
139 150
140sub parse_metadata { 151sub parse_metadata {
141 my $meta;
142
143 my $data = shift; 152 my $data = shift;
153 my $meta = { raw => $data };
154
144 if ($data =~ /^Version\015?\012/gc) { 155 if ($data =~ /^Version\015?\012/gc) {
145 my $hdr = $meta->{version} = {}; 156 my $hdr = $meta->{version} = {};
146 157
147 for (;;) { 158 for (;;) {
148 while ($data =~ /\G([^=\015\012]+)=([^\015\012]*)\015?\012/gc) { 159 while ($data =~ /\G([^=\015\012]+)=([^\015\012]*)\015?\012/gc) {
171 #$meta->{tail} = substr $data, pos $data; 182 #$meta->{tail} = substr $data, pos $data;
172 183
173 $meta; 184 $meta;
174} 185}
175 186
187=item $string = Net::FCP::build_metadata $meta
188
189Takes a hash reference as returned by C<Net::FCP::parse_metadata> and
190returns the corresponding string form. If a string is given, it's returned
191as is.
192
193=cut
194
195sub build_metadata_subhash($$$) {
196 my ($prefix, $level, $hash) = @_;
197
198 join "",
199 map
200 ref $hash->{$_} ? build_metadata_subhash ($prefix . (Net::FCP::touc $_) . ".", $level + 1, $hash->{$_})
201 : $prefix . ($level > 1 ? $_ : Net::FCP::touc $_) . "=" . $hash->{$_} . "\n",
202 keys %$hash;
203}
204
205sub build_metadata_hash($$) {
206 my ($header, $hash) = @_;
207
208 if (ref $hash eq ARRAY::) {
209 join "", map build_metadata_hash ($header, $_), @$hash
210 } else {
211 (Net::FCP::touc $header) . "\n"
212 . (build_metadata_subhash "", 0, $hash)
213 . "EndPart\n";
214 }
215}
216
217sub build_metadata($) {
218 my ($meta) = @_;
219
220 return $meta unless ref $meta;
221
222 $meta = { %$meta };
223
224 delete $meta->{raw};
225
226 my $res =
227 (build_metadata_hash version => delete $meta->{version})
228 . (join "", map +(build_metadata_hash $_, $meta->{$_}), keys %$meta);
229
230 substr $res, 0, -5; # get rid of "Part". Broken Syntax....
231}
232
233
176=item $fcp = new Net::FCP [host => $host][, port => $port] 234=item $fcp = new Net::FCP [host => $host][, port => $port][, progress => \&cb]
177 235
178Create a new virtual FCP connection to the given host and port (default 236Create a new virtual FCP connection to the given host and port (default
179127.0.0.1:8481, or the environment variables C<FREDHOST> and C<FREDPORT>). 237127.0.0.1:8481, or the environment variables C<FREDHOST> and C<FREDPORT>).
180 238
181Connections are virtual because no persistent physical connection is 239Connections are virtual because no persistent physical connection is
182established. 240established.
241
242You can install a progress callback that is being called with the Net::FCP
243object, a txn object, the type of the transaction and the attributes. Use
244it like this:
245
246 sub progress_cb {
247 my ($self, $txn, $type, $attr) = @_;
248
249 warn "progress<$txn,$type," . (join ":", %$attr) . ">\n";
250 }
183 251
184=begin comment 252=begin comment
185 253
186However, the existance of the node is checked by executing a 254However, the existance of the node is checked by executing a
187C<ClientHello> transaction. 255C<ClientHello> transaction.
203 $self; 271 $self;
204} 272}
205 273
206sub progress { 274sub progress {
207 my ($self, $txn, $type, $attr) = @_; 275 my ($self, $txn, $type, $attr) = @_;
208 #warn "progress<$txn,$type," . (join ":", %$attr) . ">\n"; 276
277 $self->{progress}->($self, $txn, $type, $attr)
278 if $self->{progress};
209} 279}
210 280
211=item $txn = $fcp->txn(type => attr => val,...) 281=item $txn = $fcp->txn(type => attr => val,...)
212 282
213The low-level interface to transactions. Don't use it. 283The low-level interface to transactions. Don't use it.
239sub txn { 309sub txn {
240 my ($self, $type, %attr) = @_; 310 my ($self, $type, %attr) = @_;
241 311
242 $type = touc $type; 312 $type = touc $type;
243 313
244 my $txn = "Net::FCP::Txn::$type"->new(fcp => $self, type => tolc $type, attr => \%attr); 314 my $txn = "Net::FCP::Txn::$type"->new (fcp => $self, type => tolc $type, attr => \%attr);
245 315
246 $txn; 316 $txn;
247} 317}
248 318
249{ # transactions 319{ # transactions
310 my ($self) = @_; 380 my ($self) = @_;
311 381
312 $self->txn ("client_info"); 382 $self->txn ("client_info");
313}); 383});
314 384
315=item $txn = $fcp->txn_generate_chk ($metadata, $data) 385=item $txn = $fcp->txn_generate_chk ($metadata, $data[, $cipher])
316 386
317=item $uri = $fcp->generate_chk ($metadata, $data) 387=item $uri = $fcp->generate_chk ($metadata, $data[, $cipher])
318 388
319Creates a new CHK, given the metadata and data. UNTESTED. 389Calculates a CHK, given the metadata and data. C<$cipher> is either
390C<Rijndael> or C<Twofish>, with the latter being the default.
320 391
321=cut 392=cut
322 393
323$txn->(generate_chk => sub { 394$txn->(generate_chk => sub {
324 my ($self, $metadata, $data) = @_; 395 my ($self, $metadata, $data, $cipher) = @_;
325 396
326 $self->txn (generate_chk => data => "$metadata$data", metadata_length => length $metadata); 397 $self->txn (generate_chk =>
398 data => "$metadata$data",
399 metadata_length => xeh length $metadata,
400 cipher => $cipher || "Twofish");
327}); 401});
328 402
329=item $txn = $fcp->txn_generate_svk_pair 403=item $txn = $fcp->txn_generate_svk_pair
330 404
331=item ($public, $private) = @{ $fcp->generate_svk_pair } 405=item ($public, $private) = @{ $fcp->generate_svk_pair }
332 406
333Creates a new SVK pair. Returns an arrayref. 407Creates a new SVK pair. Returns an arrayref with the public key, the
408private key and a crypto key, which is just additional entropy.
334 409
335 [ 410 [
336 "hKs0-WDQA4pVZyMPKNFsK1zapWY", 411 "acLx4dux9fvvABH15Gk6~d3I-yw",
337 "ZnmvMITaTXBMFGl4~jrjuyWxOWg" 412 "cPoDkDMXDGSMM32plaPZDhJDxSs",
413 "BH7LXCov0w51-y9i~BoB3g",
338 ] 414 ]
415
416A private key (for inserting) can be constructed like this:
417
418 SSK@<private_key>,<crypto_key>/<name>
419
420It can be used to insert data. The corresponding public key looks like this:
421
422 SSK@<public_key>PAgM,<crypto_key>/<name>
423
424Watch out for the C<PAgM>-part!
339 425
340=cut 426=cut
341 427
342$txn->(generate_svk_pair => sub { 428$txn->(generate_svk_pair => sub {
343 my ($self) = @_; 429 my ($self) = @_;
344 430
345 $self->txn ("generate_svk_pair"); 431 $self->txn ("generate_svk_pair");
346}); 432});
347 433
348=item $txn = $fcp->txn_insert_private_key ($private) 434=item $txn = $fcp->txn_invert_private_key ($private)
349 435
350=item $public = $fcp->insert_private_key ($private) 436=item $public = $fcp->invert_private_key ($private)
351 437
352Inserts a private key. $private can be either an insert URI (must start 438Inverts a private key (returns the public key). C<$private> can be either
353with C<freenet:SSK@>) or a raw private key (i.e. the private value you get 439an insert URI (must start with C<freenet:SSK@>) or a raw private key (i.e.
354back from C<generate_svk_pair>). 440the private value you get back from C<generate_svk_pair>).
355 441
356Returns the public key. 442Returns the public key.
357 443
358UNTESTED.
359
360=cut 444=cut
361 445
362$txn->(insert_private_key => sub { 446$txn->(invert_private_key => sub {
363 my ($self, $privkey) = @_; 447 my ($self, $privkey) = @_;
364 448
365 $self->txn (invert_private_key => private => $privkey); 449 $self->txn (invert_private_key => private => $privkey);
366}); 450});
367 451
369 453
370=item $length = $fcp->get_size ($uri) 454=item $length = $fcp->get_size ($uri)
371 455
372Finds and returns the size (rounded up to the nearest power of two) of the 456Finds and returns the size (rounded up to the nearest power of two) of the
373given document. 457given document.
374
375UNTESTED.
376 458
377=cut 459=cut
378 460
379$txn->(get_size => sub { 461$txn->(get_size => sub {
380 my ($self, $uri) = @_; 462 my ($self, $uri) = @_;
387=item ($metadata, $data) = @{ $fcp->client_get ($uri, $htl, $removelocal) 469=item ($metadata, $data) = @{ $fcp->client_get ($uri, $htl, $removelocal)
388 470
389Fetches a (small, as it should fit into memory) file from 471Fetches a (small, as it should fit into memory) file from
390freenet. C<$meta> is the metadata (as returned by C<parse_metadata> or 472freenet. C<$meta> is the metadata (as returned by C<parse_metadata> or
391C<undef>). 473C<undef>).
474
475The C<$uri> should begin with C<freenet:>, but the scheme is currently
476added, if missing.
392 477
393Due to the overhead, a better method to download big files should be used. 478Due to the overhead, a better method to download big files should be used.
394 479
395 my ($meta, $data) = @{ 480 my ($meta, $data) = @{
396 $fcp->client_get ( 481 $fcp->client_get (
401=cut 486=cut
402 487
403$txn->(client_get => sub { 488$txn->(client_get => sub {
404 my ($self, $uri, $htl, $removelocal) = @_; 489 my ($self, $uri, $htl, $removelocal) = @_;
405 490
491 $uri =~ s/^freenet://;
492 $uri = "freenet:$uri";
493
406 $self->txn (client_get => URI => $uri, hops_to_live => (defined $htl ? $htl :15), 494 $self->txn (client_get => URI => $uri, hops_to_live => xeh (defined $htl ? $htl : 15),
407 remove_local_key => $removelocal ? "true" : "false"); 495 remove_local_key => $removelocal ? "true" : "false");
408}); 496});
409 497
410=item $txn = $fcp->txn_client_put ($uri, $metadata, $data, $htl, $removelocal) 498=item $txn = $fcp->txn_client_put ($uri, $metadata, $data, $htl, $removelocal)
411 499
412=item my $uri = $fcp->client_put ($uri, $metadata, $data, $htl, $removelocal); 500=item my $uri = $fcp->client_put ($uri, $metadata, $data, $htl, $removelocal);
413 501
414Insert a new key. If the client is inserting a CHK, the URI may be 502Insert a new key. If the client is inserting a CHK, the URI may be
415abbreviated as just CHK@. In this case, the node will calculate the 503abbreviated as just CHK@. In this case, the node will calculate the
416CHK. 504CHK. If the key is a private SSK key, the node will calculcate the public
505key and the resulting public URI.
417 506
418C<$meta> can be a reference or a string (ONLY THE STRING CASE IS IMPLEMENTED!). 507C<$meta> can be a hash reference (same format as returned by
508C<Net::FCP::parse_metadata>) or a string.
419 509
420THIS INTERFACE IS UNTESTED AND SUBJECT TO CHANGE. 510The result is an arrayref with the keys C<uri>, C<public_key> and C<private_key>.
421 511
422=cut 512=cut
423 513
424$txn->(client_put => sub { 514$txn->(client_put => sub {
425 my ($self, $uri, $meta, $data, $htl, $removelocal) = @_; 515 my ($self, $uri, $meta, $data, $htl, $removelocal) = @_;
426 516
427 $self->txn (client_put => URI => $uri, hops_to_live => (defined $htl ? $htl :15), 517 $meta = build_metadata $meta;
518
519 $self->txn (client_put => URI => $uri,
520 hops_to_live => xeh (defined $htl ? $htl : 15),
428 remove_local_key => $removelocal ? "true" : "false", 521 remove_local_key => $removelocal ? "true" : "false",
429 data => "$meta$data", metadata_length => length $meta); 522 data => "$meta$data", metadata_length => xeh length $meta);
430}); 523});
431 524
432} # transactions 525} # transactions
433 526
434=item MISSING: (ClientPut), InsretKey
435
436=back 527=back
437 528
438=head2 THE Net::FCP::Txn CLASS 529=head2 THE Net::FCP::Txn CLASS
439 530
440All requests (or transactions) are executed in a asynchroneous way (LIE: 531All requests (or transactions) are executed in a asynchronous way. For
441uploads are blocking). For each request, a C<Net::FCP::Txn> object is 532each request, a C<Net::FCP::Txn> object is created (worse: a tcp
442created (worse: a tcp connection is created, too). 533connection is created, too).
443 534
444For each request there is actually a different subclass (and it's possible 535For each request there is actually a different subclass (and it's possible
445to subclass these, although of course not documented). 536to subclass these, although of course not documented).
446 537
447The most interesting method is C<result>. 538The most interesting method is C<result>.
475 while (my ($k, $v) = each %{$self->{attr}}) { 566 while (my ($k, $v) = each %{$self->{attr}}) {
476 $attr .= (Net::FCP::touc $k) . "=$v\012" 567 $attr .= (Net::FCP::touc $k) . "=$v\012"
477 } 568 }
478 569
479 if (defined $data) { 570 if (defined $data) {
480 $attr .= "DataLength=" . (length $data) . "\012"; 571 $attr .= sprintf "DataLength=%x\012", length $data;
481 $data = "Data\012$data"; 572 $data = "Data\012$data";
482 } else { 573 } else {
483 $data = "EndMessage\012"; 574 $data = "EndMessage\012";
484 } 575 }
485 576
492 and !$!{EINPROGRESS} 583 and !$!{EINPROGRESS}
493 and Carp::croak "FCP::txn: unable to connect to $self->{fcp}{host}:$self->{fcp}{port}: $!\n"; 584 and Carp::croak "FCP::txn: unable to connect to $self->{fcp}{host}:$self->{fcp}{port}: $!\n";
494 585
495 $self->{sbuf} = 586 $self->{sbuf} =
496 "\x00\x00\x00\x02" 587 "\x00\x00\x00\x02"
497 . Net::FCP::touc $self->{type} 588 . (Net::FCP::touc $self->{type})
498 . "\012$attr$data"; 589 . "\012$attr$data";
499 590
500 #$fh->shutdown (1); # freenet buggy?, well, it's java... 591 #shutdown $fh, 1; # freenet buggy?, well, it's java...
501 592
502 $self->{fh} = $fh; 593 $self->{fh} = $fh;
503 594
504 $self->{w} = $EVENT->new_from_fh ($fh)->cb(sub { $self->fh_ready_w })->poll(0, 1, 1); 595 $self->{w} = $EVENT->new_from_fh ($fh)->cb(sub { $self->fh_ready_w })->poll(0, 1, 1);
505 596
664 } 755 }
665} 756}
666 757
667sub progress { 758sub progress {
668 my ($self, $type, $attr) = @_; 759 my ($self, $type, $attr) = @_;
760
669 $self->{fcp}->progress ($self, $type, $attr); 761 $self->{fcp}->progress ($self, $type, $attr);
670} 762}
671 763
672=item $result = $txn->result 764=item $result = $txn->result
673 765
674Waits until a result is available and then returns it. 766Waits until a result is available and then returns it.
675 767
676This waiting is (depending on your event model) not very efficient, as it 768This waiting is (depending on your event model) not very efficient, as it
677is done outside the "mainloop". 769is done outside the "mainloop". The biggest problem, however, is that it's
770blocking one thread of execution. Try to use the callback mechanism, if
771possible, and call result from within the callback (or after is has been
772run), as then no waiting is necessary.
678 773
679=cut 774=cut
680 775
681sub result { 776sub result {
682 my ($self) = @_; 777 my ($self) = @_;
713use base Net::FCP::Txn; 808use base Net::FCP::Txn;
714 809
715sub rcv_success { 810sub rcv_success {
716 my ($self, $attr) = @_; 811 my ($self, $attr) = @_;
717 812
718 $self->set_result ($attr); 813 $self->set_result ($attr->{uri});
719} 814}
720 815
721package Net::FCP::Txn::GenerateSVKPair; 816package Net::FCP::Txn::GenerateSVKPair;
722 817
723use base Net::FCP::Txn; 818use base Net::FCP::Txn;
724 819
725sub rcv_success { 820sub rcv_success {
726 my ($self, $attr) = @_; 821 my ($self, $attr) = @_;
727 $self->set_result ([$attr->{PublicKey}, $attr->{PrivateKey}]); 822 $self->set_result ([$attr->{public_key}, $attr->{private_key}, $attr->{crypto_key}]);
728} 823}
729 824
730package Net::FCP::Txn::InsertPrivateKey; 825package Net::FCP::Txn::InvertPrivateKey;
731 826
732use base Net::FCP::Txn; 827use base Net::FCP::Txn;
733 828
734sub rcv_success { 829sub rcv_success {
735 my ($self, $attr) = @_; 830 my ($self, $attr) = @_;
736 $self->set_result ($attr->{PublicKey}); 831 $self->set_result ($attr->{public_key});
737} 832}
738 833
739package Net::FCP::Txn::GetSize; 834package Net::FCP::Txn::GetSize;
740 835
741use base Net::FCP::Txn; 836use base Net::FCP::Txn;
742 837
743sub rcv_success { 838sub rcv_success {
744 my ($self, $attr) = @_; 839 my ($self, $attr) = @_;
745 $self->set_result ($attr->{Length}); 840 $self->set_result (hex $attr->{length});
746} 841}
747 842
748package Net::FCP::Txn::GetPut; 843package Net::FCP::Txn::GetPut;
749 844
750# base class for get and put 845# base class for get and put
751 846
752use base Net::FCP::Txn; 847use base Net::FCP::Txn;
753 848
754*rcv_uri_error = \&Net::FCP::Txn::rcv_throw_exception; 849*rcv_uri_error = \&Net::FCP::Txn::rcv_throw_exception;
755*rcv_route_not_found = \&Net::FCP::Txn::rcv_throw_exception; 850*rcv_route_not_found = \&Net::FCP::Txn::rcv_throw_exception;
756 851
757sub rcv_restarted { 852sub rcv_restarted {
758 my ($self, $attr, $type) = @_; 853 my ($self, $attr, $type) = @_;
759 854
760 delete $self->{datalength}; 855 delete $self->{datalength};
780 if ($self->{datalength} == length $self->{data}) { 875 if ($self->{datalength} == length $self->{data}) {
781 my $data = delete $self->{data}; 876 my $data = delete $self->{data};
782 my $meta = Net::FCP::parse_metadata substr $data, 0, $self->{metalength}, ""; 877 my $meta = Net::FCP::parse_metadata substr $data, 0, $self->{metalength}, "";
783 878
784 $self->set_result ([$meta, $data]); 879 $self->set_result ([$meta, $data]);
880 $self->eof;
785 } 881 }
786} 882}
787 883
788sub rcv_data_found { 884sub rcv_data_found {
789 my ($self, $attr, $type) = @_; 885 my ($self, $attr, $type) = @_;
827 923
828package Net::FCP::Exception; 924package Net::FCP::Exception;
829 925
830use overload 926use overload
831 '""' => sub { 927 '""' => sub {
832 "Net::FCP::Exception<<$_[0][0]," . (join ":", %{$_[0][1]}) . ">>\n"; 928 "Net::FCP::Exception<<$_[0][0]," . (join ":", %{$_[0][1]}) . ">>";
833 }; 929 };
834 930
835=item $exc = new Net::FCP::Exception $type, \%attr 931=item $exc = new Net::FCP::Exception $type, \%attr
836 932
837Create a new exception object of the given type (a string like 933Create a new exception object of the given type (a string like
889 Marc Lehmann <pcg@goof.com> 985 Marc Lehmann <pcg@goof.com>
890 http://www.goof.com/pcg/marc/ 986 http://www.goof.com/pcg/marc/
891 987
892=cut 988=cut
893 989
990package Net::FCP::Event::Auto;
991
992my @models = (
993 [Coro => Coro::Event::],
994 [Event => Event::],
995 [Glib => Glib::],
996 [Tk => Tk::],
997);
998
999sub AUTOLOAD {
1000 $AUTOLOAD =~ s/.*://;
1001
1002 for (@models) {
1003 my ($model, $package) = @$_;
1004 if (defined ${"$package\::VERSION"}) {
1005 $EVENT = "Net::FCP::Event::$model";
1006 eval "require $EVENT"; die if $@;
1007 goto &{"$EVENT\::$AUTOLOAD"};
1008 }
1009 }
1010
1011 for (@models) {
1012 my ($model, $package) = @$_;
1013 $EVENT = "Net::FCP::Event::$model";
1014 if (eval "require $EVENT") {
1015 goto &{"$EVENT\::$AUTOLOAD"};
1016 }
1017 }
1018
1019 die "No event module selected for Net::FCP and autodetect failed. Install any of these: Coro, Event, Glib or Tk.";
1020}
1021
8941; 10221;
895 1023

Diff Legend

Removed lines
+ Added lines
< Changed lines
> Changed lines